docs: rewrite JsonRpcBundle docs, add SDK docs, move them under docs/api
en/docs/api/mcp-bundle/developers/README.md
new file +71
−0
For developers
How MCP Bundle works inside: principles, components, the request path and what is cached. The bundle is an extension of JsonRpcBundle and follows the core's general rules for extensions.
Principles
- A thin adapter over JsonRpcBundle. Like REST Bundle, this bundle adds a protocol, not a second server. Business logic stays in the RPC services (
IRpcService); a tool call is a regular RPC request executed by the core. - Reuse, don't duplicate. The service map, API groups and versions, translations, language negotiation, token security, cache pools, route import and request execution come from the core. When the core lacks an extension point, it is added to the core: that is how
IApiRouteImportin 10.4.1 andRpcExecutorin 10.4.2 appeared. - The route is the source of truth. The API group comes from the route import (
api_group), the version from the{ver}segment. The configuration only tunes the group's server. - Publication = API group membership. Every method of the group becomes a tool; there is no allowlist and no separate MCP registration.
- The official SDK implements the protocol. The handshake, sessions, the stateless 2026-07-28 protocol, argument validation against
inputSchema, batching, the CORS and DNS rebinding middleware aremcp/sdk(^0.8.1). The bundle builds an SDK server per request and wires the RPC methods into it. - Fail loudly. A method whose name is not a valid MCP tool name breaks its group (HTTP 500 with an explanation in debug) instead of silently disappearing from the tool list.
Components
| Component | Role |
|---|---|
Routing\McpRouteImport |
Registers config/router.yaml with the core route loader (IApiRouteImport): every import gets <group>_ufo_mcp_* names and the _mcp_name_prefix attribute |
Controller\McpController |
Three actions: the MCP endpoint, server.json, client manifests |
EventListener\McpRequestListener |
kernel.controller listener for McpController only: API group, availability (404), token (401/403) |
Server\McpHandler |
Language negotiation, transport, SDK server, Content-Language / Vary |
Server\Transport\StreamableHttpTransportFactory |
The SDK Streamable HTTP transport with CORS, DNS rebinding protection and the group's request body limit |
Server\McpServerFactory |
The SDK server for one request: identity, instructions, session store, one tool per method of the group and version |
Server\Session\RpcCacheSessionStore |
Session-based protocol sessions in the core cache pool rpc.cache.pool |
Tool\ToolSchemaFactory |
An MCP Tool from the core Service metadata: name check, texts, inputSchema, outputSchema, annotations; cached in the core RpcMetadataCache |
Tool\RpcToolHandler, Tool\RpcToolExecutor |
tools/call → core RpcExecutor → CallToolResult |
Server\RequestPrepare\Holders\RpcFromMcp |
The request carrier (IRpcRequestCarrier): an RpcRequest from the tool, arguments and version |
DocAdapters\Outputs\ServerJsonAdapter |
server.json for the MCP Registry |
ClientManifest\* |
Client manifests (Claude Code, Cursor, Gemini CLI, OpenAI), a tagged locator |
ConfigService\McpConfig, McpGroupConfig, McpServerInfoConfig |
Typed view of the ufo_mcp configuration; a group without an entry gets the bundle-wide settings |
Security\McpAccessPolicyInterface, Availability\McpAvailabilityCheckerInterface |
Extension points; see Extensions |
Request path
HTTP request — route from config/router.yaml, imported once per API group
→ McpRequestListener (kernel.controller, McpController only)
ApiGroupContext ← route api_group / _mcp_name_prefix
404: group disabled (McpAvailabilityCheckerInterface) or {ver} not among the group's ServiceMap versions
OPTIONS (CORS preflight) passes without a token
core IRpcSecurity: isValidApiRequest() for the endpoint, isValidDocRequest() for server.json / manifests
401 (no token) / 403 (invalid token) as {"error": "..."}
→ McpController::mcpAction → McpHandler::handle()
core RpcLocaleNegotiator → request language
StreamableHttpTransportFactory → PSR-7 request + SDK transport (CORS, DNS rebinding, max_body_bytes)
McpServerFactory::create($ver, $locale) → SDK Server (tools of ServiceMap::getServices($ver) of the group)
SDK: initialize / ping / tools/list / tools/call / DELETE session ...
Content-Language, Vary: Accept-Language
→ tools/call → RpcToolHandler → RpcToolExecutor
RpcFromMcp(service, arguments, version) → core RpcExecutor::execute()
(same pipeline as /rpc; an escaping exception goes through rpc.error, the response is always an RpcResponse)
core RpcRequestHandler::responseToArray() → CallToolResult (text + structuredContent | isError)
→ kernel.terminate: core PostResponseQueue fires rpc.post_response for every executed call
More about the core stages: Lifecycle.
What is built per request, what is cached
- The SDK server and the tool list are created for every HTTP request: the server keeps no state between requests, sessions live in the cache pool.
- Every
Toolis cached in the coreRpcMetadataCache(cache.system), keyed by group, version, method and language. The cache is reset on every container rebuild and disabled in debug, the same rule the core applies to its own documentation. - A compiler pass is deliberately not used: the container does not track DTO classes, and building schemas needs application services.
- Sessions (session-based protocol only) live in
rpc.cache.poolunder the keyufo_mcp_<group>_<session id>withsession_ttlper item.
In this section
- Extensions: extension points of the bundle and the core.
- Testing: test suites, fixtures, coverage, MCP Inspector.
- Known issues: what does not work as expected and how to work around it.
Bugs and proposals go to the bundle repository's issue tracker.