permissionEnforcer = $permissionEnforcer; } /** * Handles post requests * * @param array $params - value of $_POST * * @throws BindingResolutionException * @throws \ReflectionException */ public function post(array $params): Response { // Remove act from params array if (isset($params['act'])) { unset($params['act']); } // If params is empty, maybe it was in the body, get body if (empty($params)) { try { $params = $this->getJsonFromBody(); } catch (MissingParameterException $e) { Log::error($e); return $this->returnMethodNotFound('Could not get any parameters from body'); } catch (\JsonException $e) { Log::error($e); return $this->returnParseError('Could not parse JSON. Error '.$e->getMessage()); } } // params['params'] could be array (single value) or json object if (isset($params['params'])) { if (! is_array($params['params'])) { $params['params'] = json_decode($params['params'], true); } } return $this->executeApiRequest($params); } /** * Handles get requests * * @param array $params - value of $_GET * * @throws BindingResolutionException * @throws \ReflectionException */ public function get(array $params): Response { if (! isset($params['method'])) { return $this->returnInvalidRequest('Method name required'); } /** * Decode params * * @see https://www.jsonrpc.org/historical/json-rpc-over-http.html#get */ if (isset($params['params'])) { $paramsDecoded = base64_decode(urldecode($params['params'])); } else { $paramsDecoded = []; } $params = [ 'method' => $params['method'], 'params' => $paramsDecoded, 'id' => $params['id'] ?? null, 'jsonrpc' => $params['jsonrpc'] ?? '', ]; $params['params'] = json_decode($params['params'], true); // check if decode failed if ($params == null) { return $this->returnParseError('JSON is invalid and was not able to be parsed'); } return $this->executeApiRequest($params); } private function getJsonFromBody(): array { if ($this->incomingRequest->server('REQUEST_METHOD') === 'POST' && empty($_POST) && $this->incomingRequest->getContent() !== null && $this->incomingRequest->getContent() !== false && $this->incomingRequest->getContent() !== '') { $bodyContent = json_decode( json: $this->incomingRequest->getContent(), associative: true, flags: JSON_THROW_ON_ERROR ); return $bodyContent; } throw new MissingParameterException('Could not get JSON from body or form fields'); } /** * Handles patch requests */ public function patch(): Response { return $this->returnInvalidRequest('The JSON-RPC API only supports POST/GET requests'); } /** * Handles delete requests */ public function delete(): Response { return $this->returnInvalidRequest('The JSON-RPC API only supports POST/GET requests'); } /** * executes api call * * @param array $params - request body * * @throws BindingResolutionException * @throws \ReflectionException */ private function executeApiRequest(array $params): Response { /** * checks to see if array keys are incremented, if so, assume it's a batch request * * @see https://jsonrpc.org/specification#batch */ if (array_keys($params) == range(0, count($params) - 1)) { return new JsonResponse(array_map( function ($requestParams) { return json_decode($this->executeApiRequest($requestParams)->getContent()); }, $params )); } $id = $params['id'] ?? null; try { $methodparts = $this->parseMethodString($params['method'] ?? ''); } catch (Exception $e) { return $this->returnInvalidParams($e, $id); } $jsonRpcVer = $params['jsonrpc'] ?? null; $moduleName = Str::studly($methodparts['module']); $serviceName = Str::studly($methodparts['service']); $domainServiceNamespace = app()->getNamespace()."Domain\\$moduleName\\Services\\$serviceName"; $pluginServiceNamespace = app()->getNamespace()."Plugins\\$moduleName\\Services\\$serviceName"; // Plugins may expose JSON-RPC methods from a Tools/ directory too — the same // directory McpToolDiscovery scans for #[UnifiedTool]-tagged classes. This // lets one class serve both MCP discovery and JSON-RPC dispatch without // moving the file or duplicating it under Services/. $pluginToolNamespace = app()->getNamespace()."Plugins\\$moduleName\\Tools\\$serviceName"; $methodName = Str::camel($methodparts['method']); $paramsFromRequest = $params['params'] ?? []; if (class_exists($domainServiceNamespace)) { $serviceName = $domainServiceNamespace; } elseif (class_exists($pluginServiceNamespace)) { $serviceName = $pluginServiceNamespace; } elseif (class_exists($pluginToolNamespace)) { $serviceName = $pluginToolNamespace; } else { return $this->returnMethodNotFound("Service doesn't exist: $serviceName", $id); } if (! method_exists($serviceName, $methodName)) { return $this->returnMethodNotFound("Method doesn't exist: $methodName", $id); } // Only allow methods explicitly marked with @api annotation if (! $this->isApiMethod($serviceName, $methodName)) { return $this->returnMethodNotFound("Method is not available via API: $methodName", $id); } // Enforce plugin-gated methods. Methods or classes carrying #[RequiresPlugin('Name')] // refuse to dispatch when the named plugin is disabled — return a JSON-RPC error // with HTTP 200 body, mirroring the returnMethodNotFound pattern above. // // Uses the Domain Plugins service (DB-backed user plugins) rather than // Core\Plugins\Plugins (env-driven system plugins only). This matches what // config.getSystemInfo reports, so the gate and the client-visible capability // list share one source of truth. $requiredPlugin = $this->getRequiredPlugin($serviceName, $methodName); if ($requiredPlugin !== null && ! app()->make(PluginsService::class)->isEnabled($requiredPlugin)) { return $this->returnError( "Plugin '$requiredPlugin' is required but not enabled.", -32004, null, $id ); } if ($jsonRpcVer == null) { return $this->returnInvalidRequest('You must include a "jsonrpc" parameter with a value of "2.0"', $id); } if ($jsonRpcVer !== '2.0') { return $this->returnInvalidRequest('Leantime only supports JSON-RPC version 2.0', $id); } try { $methodParams = $this->getMethodParameters($serviceName, $methodName); } catch (\ReflectionException $e) { return $this->returnServerError("Error getting parameters: $e", $id); } try { $preparedParams = $this->prepareParameters($paramsFromRequest, $methodParams); } catch (Exception $e) { return $this->returnInvalidParams($e, $id); } // can be null try { // RPC bypasses the controller gate, so per-method authorization is enforced here: // a #[RequiresPermission] on the resolved service method is checked before the call. // A denial throws AuthorizationException, mapped below to JSON-RPC -32001. $this->permissionEnforcer->enforce($serviceName, $methodName, is_array($paramsFromRequest) ? $paramsFromRequest : []); $method_response = app()->make($serviceName)->$methodName(...$preparedParams); } catch (\Throwable $e) { // Leantime exceptions carry a client-safe code/message/data and map to a precise // JSON-RPC error. Anything else is an unexpected failure that must be logged and // collapsed to a generic server error so internal detail never reaches the caller. if (! $e instanceof LeantimeExceptionInterface) { Log::error($e); } // A notification (no id) must not be responded to, even on failure, per the // JSON-RPC 2.0 spec — mirror the success path's empty 200. if ($id === null) { return new Response('', Response::HTTP_OK); } return JsonRpcErrorResponse::fromException($e, $id)->toResponse($this->incomingRequest); } // Convert objects to associative arrays for JSON serialization, but pass // scalars and arrays through as-is. The previous `settype($var, 'array')` // coerced scalars to `[$scalar]`, which broke RPC methods returning ints // (e.g., addTicket returning a new ticket ID). if ($method_response !== null && is_object($method_response)) { $method_response = (array) $method_response; } return $this->returnResponse($method_response, $id); } /** * Parses the method string * * @param string $methodstring - leantime.rpc.service.method * * @throws Exception */ private function parseMethodString(string $methodstring): array { if (empty($methodstring)) { throw new Exception('Must include method'); } if (! str_starts_with($methodstring, 'leantime.rpc.')) { throw new Exception("Method string doesn't start with \"leantime.rpc.\""); } // method parameter breakdown // 00000000.111.22222222.3333333333333.444444444444 // leantime.rpc.{module}.{servicename}.{methodname} $methodStringPieces = explode('.', $methodstring); if (count($methodStringPieces) !== 4 && count($methodStringPieces) !== 5) { throw new Exception('Method is case sensitive and must follow the following naming convention: "leantime.rpc.{domain}.{servicename}.{methodname}"'); } if (count($methodStringPieces) === 4) { return [ 'module' => $methodStringPieces[2], 'service' => $methodStringPieces[2], 'method' => $methodStringPieces[3], ]; } if (count($methodStringPieces) === 5) { return [ 'module' => $methodStringPieces[2], 'service' => $methodStringPieces[3], 'method' => $methodStringPieces[4], ]; } } /** * Checks if a service method is marked with the @api annotation. * * @param string $serviceName Fully qualified class name * @param string $methodName Method name * @return bool True if the method has an @api docblock tag */ private function isApiMethod(string $serviceName, string $methodName): bool { try { $reflection = new ReflectionMethod($serviceName, $methodName); $docComment = $reflection->getDocComment(); if ($docComment === false) { return false; } // Match the @api tag only at the START of a docblock line (" * @api"), so an // explanatory prose mention (e.g. "@internal not exposed via JSON-RPC, unlike @api // methods") can never accidentally re-expose a deliberately-internal method. return (bool) preg_match('/^\s*\*\s*@api\b/m', $docComment); } catch (\ReflectionException $e) { return false; } } /** * Resolve the plugin name a method (or its declaring class) requires, if any. * * Looks for the RequiresPlugin attribute on the method first, then the class. * Method-level wins over class-level. * * @return string|null The required plugin folder name, or null if not gated */ private function getRequiredPlugin(string $serviceName, string $methodName): ?string { try { $method = new ReflectionMethod($serviceName, $methodName); $attrs = $method->getAttributes(RequiresPlugin::class); if (! empty($attrs)) { return $attrs[0]->newInstance()->pluginName; } $class = new ReflectionClass($serviceName); $classAttrs = $class->getAttributes(RequiresPlugin::class); if (! empty($classAttrs)) { return $classAttrs[0]->newInstance()->pluginName; } } catch (\ReflectionException $e) { return null; } return null; } /** * Gets the Method Parameters * * * * @throws \ReflectionException */ private function getMethodParameters(string $servicename, string $methodname): array { return (new ReflectionClass($servicename)) ->getMethod($methodname) ->getParameters(); } /** * Checks request params * * * * @throws Exception */ private function prepareParameters(array $params, array $methodParams): array { $filtered_parameters = []; // matches params, params that don't match are ignored foreach ($methodParams as $methodParam) { $required = ! $methodParam->isDefaultValueAvailable(); $position = $methodParam->getPosition(); $name = $methodParam->name; $type = $methodParam->getType(); // check if param is there if (! in_array($name, array_keys($params))) { if ($required) { throw new Exception("Required Parameter Missing: $name"); } $filtered_parameters[$position] = $methodParam->getDefaultValue(); continue; } // check if type is correct or can be correct if ($methodParam->hasType()) { if (in_array($type, [gettype($params[$name]), 'mixed'])) { $filtered_parameters[$position] = $params[$name]; continue; } if ($params[$name] === null && ! $type->allowsNull()) { throw new Exception("Parameter $name can't be null"); } try { $filtered_parameters[$position] = cast($params[$name], $type->getName()); } catch (\Throwable $e) { Log::error($e); throw new \Exception("Could not cast parameter: $name. See server logs for more details."); } } if (! isset($filtered_parameters[$position])) { $filtered_parameters[$position] = $params[$name]; } } // make sure it is in the right order ksort($filtered_parameters); return $filtered_parameters; } /** * Echos the return response. * * @param mixed $returnValue The return value from the RPC method. Widened from * `?array` because the upstream `settype` coercion that * wrapped scalars into single-element arrays was removed * (it broke methods returning ints — e.g. addTicket's * new ticket id was being delivered as [id]). Per the * JSON-RPC 2.0 spec §5, `result` MAY be any JSON value; * caller code in `executeRPC` already casts objects to * associative arrays before reaching here, so in practice * this is array|scalar|null. * * @see https://jsonrpc.org/specification#response_object */ private function returnResponse(mixed $returnValue, int|string|null $id = null): Response { return (new JsonRpcResponse($returnValue, $id))->toResponse($this->incomingRequest); } /** * Return error response * * @see https://jsonrpc.org/specification#error_object */ private function returnError(string $errorMessage, int $errorcode, mixed $additional_info = null, int|string|null $id = 0): Response { // Protocol-level callers (parse / invalid-request / method-not-found / invalid-params) // may pass their own thrown exception for context; surface only its message. Service- // level exceptions never reach here — they go through JsonRpcErrorResponse::fromException(). $data = $additional_info instanceof \Throwable ? $additional_info->getMessage() : $additional_info; return (new JsonRpcErrorResponse($errorcode, $errorMessage, $data, $id))->toResponse($this->incomingRequest); } /** * Returns a parse error * * @see https://jsonrpc.org/specification#error_object */ private function returnParseError(mixed $additional_info = null, int|string|null $id = 0): Response { return $this->returnError('Parse error', -32700, $additional_info, $id); } /** * Returns an invalid request error * * @see https://jsonrpc.org/specification#error_object */ private function returnInvalidRequest(mixed $additional_info = null, int|string|null $id = 0): Response { return $this->returnError('Invalid Request', -32600, $additional_info, $id); } /** * Returns a method not found error * * @see https://jsonrpc.org/specification#error_object */ private function returnMethodNotFound(mixed $additional_info = null, int|string|null $id = 0): Response { return $this->returnError('Method not found', -32601, $additional_info, $id); } /** * Returns an invalid parameters error * * @see https://jsonrpc.org/specification#error_object */ private function returnInvalidParams(mixed $additional_info = null, int|string|null $id = 0): Response { return $this->returnError('Invalid params', -32602, $additional_info, $id); } /** * Returns a server error * * @see https://jsonrpc.org/specification#error_object * * @param mixed|null $additional_info */ private function returnServerError(mixed $additional_info, int|string|null $id = 0): Response { return $this->returnError('Server error', -32000, $additional_info, $id); } }