HTTP and security

← HTTP and security

Ashterix

docs: rewrite JsonRpcBundle docs, add SDK docs, move them under docs/api

en/docs/api/mcp-bundle/http_security.md new file +71 −0

HTTP and security

Protocol versions

The transport is Streamable HTTP from the official mcp/sdk. It serves both MCP protocol eras on the same URL:

  • Session-based (2024-11-05 … 2025-11-25): initialize → Mcp-Session-Id header → requests with that header; DELETE ends the session. Sessions are stored in the core cache pool (see Configuration). A request without a session gets 400, an unknown or expired session gets 404 (the client repeats initialize).
  • Stateless 2026-07-28: no handshake and no session. Every request carries params._meta["io.modelcontextprotocol/protocolVersion"] and the MCP-Protocol-Version, Mcp-Method headers (plus Mcp-Name for tools/call); the result carries _meta["io.modelcontextprotocol/serverInfo"].

Verified by end-to-end tests with the official SDK client over HTTP on versions 2025-06-18, 2025-11-25 and 2026-07-28, and with MCP Inspector.

GET (a separate server-to-client SSE stream) answers 405: the server sends nothing on its own initiative. JSON-RPC batches are accepted.

Order of checks

McpRequestListener runs before every action of the bundle's controller (endpoint, server.json, manifests):

  1. 404 with an empty body: the group is disabled (McpAvailabilityCheckerInterface, by default the enabled parameter) or the group has no methods in the requested version {ver}. This check runs before the token check, so an unavailable endpoint looks the same with or without a token.
  2. OPTIONS (CORS preflight) passes without a token; the transport answers it.
  3. Token, via the core IRpcSecurity:
    • MCP endpoint: isValidApiRequest(), so protected_api, token validators and the group's security.providers apply;
    • server.json and manifests: isValidDocRequest(), protected only when protected_doc is enabled;
    • no token → 401 {"error": "Unauthorized. Token not found"}; invalid → 403 {"error": "Forbidden. Invalid token"}.

The action is identified by the controller method, not by the route name, so a name_prefix on the import cannot bypass the check.

Token header

The core token header is used (by default Ufo-RPC-Token, parameter ufo_json_rpc.security.token_name; see JsonRpcBundle configuration). The CORS Access-Control-Allow-Headers header lists it together with the MCP headers. Clients that support custom headers (Claude Code, Cursor, Gemini CLI, OpenAI Responses API) send it; the client manifests put it in place.

OAuth is not implemented

There is no /.well-known/oauth-protected-resource and no WWW-Authenticate header in the 401 response. The MCP specification makes authorization optional, but OAuth-only clients cannot connect (for example, MCP Inspector without the token header starts its own OAuth flow).

CORS and DNS rebinding

McpAccessPolicyInterface (by default DefaultMcpAccessPolicy) controls two SDK transport layers:

  • getAllowedOrigins(): CORS. Default ['*']; [] means no CORS headers are sent.
  • getAllowedHosts(): DNS rebinding protection. Default [], meaning off. If a list is set, a request whose Host is not in it gets 403. When the request has an Origin header, the SDK also checks the Origin's host against the same list, so the browser client's host must be in the list too.
use Ufo\McpBundle\Security\McpAccessPolicyInterface;

final class AppMcpAccessPolicy implements McpAccessPolicyInterface
{
    public function getAllowedHosts(): array
    {
        return ['api.example.com', 'app.example.com'];
    }

    public function getAllowedOrigins(): array
    {
        return ['https://app.example.com'];
    }
}
# config/services.yaml
services:
    Ufo\McpBundle\Security\McpAccessPolicyInterface: '@App\Mcp\AppMcpAccessPolicy'

The policy applies to all groups. The token is the main protection; CORS and the host check are additional layers.

Request body limit

max_body_bytes (bundle-wide or per group): for a larger request body the endpoint answers 413 with a JSON-RPC error.