Skip to content

MCP Server

Whity Core exposes a Model Context Protocol endpoint that AI clients can use to call platform tools, read resources, and run prompts — all with the same tenant isolation and RBAC that govern the regular HTTP API. This document is the authoritative reference for the implementation; file paths are cited throughout so the source can be read alongside this text.

Related: MCP-Client-Connection · MCP-Operator-Runbook · Plugin-Development (§ MCP contribution point) · TENANT_ISOLATION · PERMISSION_SYSTEM · AUDIT_TRAIL.


The MCP surface is not a separate process. It is served by the same FrankenPHP persistent worker that handles ordinary HTTP requests, via a dedicated route:

POST /mcp ← JSON-RPC 2.0 message (request, notification, or batch)
GET /mcp ← 405: this server offers no standing SSE stream

There is deliberately no standing server-to-client stream. Under FrankenPHP a held connection occupies a worker for its whole life, and there are eight of them — eight subscribed MCP clients would leave nothing to serve HTTP with. Server-initiated notifications are instead framed onto the POST response as text/event-stream, which the MCP spec allows and which writes a complete body in one pass rather than pinning a worker. See Change notifications.

The call path is:

POST /mcp
└─ McpTransportHandler::handlePost() src/Mcp/Transport/McpTransportHandler.php
└─ Dispatcher::handle() src/Mcp/JsonRpc/Dispatcher.php
├─ TokenValidator::validateMcpToken()
├─ TenantContext::setTenantId()
├─ McpRateLimiter::checkAndRecord()
└─ MethodHandler (by method name)
├─ initialize → InitializeHandler
├─ tools/list → ToolsListHandler
├─ tools/call → ToolsCallHandler
├─ resources/list → ResourcesListHandler
├─ resources/read → ResourcesReadHandler
├─ prompts/list → PromptsListHandler
├─ prompts/get → PromptsGetHandler
├─ ping → PingHandler
└─ notifications/cancelled → CancelledNotificationHandler

McpTransportHandler (src/Mcp/Transport/McpTransportHandler.php) is a thin HTTP layer: it gates on Content-Type: application/json, extracts the Bearer token from the Authorization header, delegates to the Dispatcher, and maps transport-level exceptions (McpRateLimitException → 429, McpFeatureDisabledException → 403) to HTTP status codes. All JSON-RPC parsing, auth, tenant context, and method routing live in the Dispatcher.

NullMcpDispatcher (src/Mcp/Transport/NullMcpDispatcher.php) is wired when MCP is disabled at the infrastructure level; it returns an empty string for every call so the transport layer can still return 503 cleanly.


ToolDeriver (src/Mcp/Tools/ToolDeriver.php) converts route declarations into MCP tool definitions at tools/list call time. It reads from two sources:

  1. Static declarations — passed to the constructor as $staticDeclarations (the core API route list).
  2. Router-native routes — the Router is queried at derivation time (not at construction), so plugin routes registered after ToolDeriver is built are picked up automatically.

Only declarations with a non-empty schema array are included. A route declaration with schema: null or schema: [] is silently skipped.

The tool name is taken directly from schema['operationId'] when that key is a non-empty string. When operationId is absent, a deterministic name is derived:

name = strtolower(method) . '_' . slug(path)

where slug(path) replaces every run of non-alphanumeric characters with an underscore and strips leading/trailing underscores. Routing constraints in path segments ({id:\d+}) are stripped before slugging, so /api/users/{id:\d+} and /api/users/{id} both yield the same operationId.

This logic mirrors SchemaGenerator::operationId() so MCP tool names stay in sync with the OpenAPI spec.

Examples:

HTTP Derived operationId
GET /api/users get_api_users
POST /api/users post_api_users
GET /api/users/{id:\d+} get_api_users_{id}
schema['operationId'] = 'listUsers' listUsers

The description field is taken from schema['summary'] when present. When absent, a human-readable summary is generated from the HTTP method:

Method Generated prefix
GET Get
POST Create
PUT / PATCH Update
DELETE Delete

The result is "{prefix} {path}", e.g. Get /api/users.

Annotations (read-only, destructive, idempotent)

Section titled “Annotations (read-only, destructive, idempotent)”

Every derived tool carries MCP annotations, so a client can run reads without asking and confirm before destructive calls (WHIT-706). title is the route’s summary. The hints come from the HTTP method:

Method readOnlyHint destructiveHint idempotentHint
GET true — —
PUT false true true
DELETE false true true
POST, PATCH false unset false

openWorldHint is false on every tool: they act on this server’s own data.

destructiveHint is left unset on POST and PATCH on purpose. The spec reads an unset hint as true, and a POST here is as often an action (trash, revoke, disable two-factor) as a create, so these keep their confirmation prompt. A route that is genuinely additive declares it:

'schema' => [
'summary' => 'Add a comment',
'mcp_annotations' => ['destructiveHint' => false],
],

mcp_annotations overrides any derived hint, and its title replaces the summary. A plugin-authored tool (PluginMcpToolsInterface, SDK 1.45) declares annotations in its descriptor. Nothing is derived for it, since it has no HTTP method. Unknown keys and non-boolean hints are dropped. Annotations are hints: RBAC, scope and the per-call checks are unchanged. Nor do they decide whether an AI agent must ask first: core decides that itself (WHIT-912, SDK 1.71). A core route reads only when core declares it does; a plugin route reads only when its method is GET or HEAD, and a readOnlyHint: true on any other method is published as false; a plugin-authored tool is a write for agents. Core’s decision travels as _meta["dev.whity/readsOnly"].

buildInputSchema() assembles a flat JSON Schema object by merging three sources in order:

  1. Path parameters — extracted from {name} or {name:constraint} segments of the route path. Always required. Type is integer when the constraint is \d+ or [0-9]+; string otherwise. Each is described: by the route’s own in: path entry in schema['parameters'] when it has one, otherwise from the collection segment before it (/api/users/{id} gives “ID of the user.”, /api/meetings/{id}/agenda/{itemId} gives “ID of the agenda item.”).

  2. Query parameters — declared in schema['parameters'] with in: query. Optional unless required: true. Each parameter’s type and description come from its schema sub-key.

  3. Request body — resolved from schema['request'], which may be:

    • a string component name: looked up in the global $components map first, then in schema['components'] on the route declaration itself.
    • an inline schema array: merged directly (bodies with a content key are skipped as they represent multipart or custom content types that cannot be expressed as a flat object).

    The resolved body schema’s properties and required arrays are merged into the accumulator.

The final result always has "type": "object". A properties key is omitted when the tool has no declared parameters. A required array is omitted when no parameters are required.

Argument descriptions (WHIT-711): an agent fills a tool in from its argument descriptions, so every argument of a core tool must have one. Describe a body field in its CoreApiSchemas component, with 'description' => '...' inside a literal or + ['description' => '...'] after a helper such as self::int(). ToolArgumentDescriptionsTest fails on an undescribed argument. Its KNOWN_UNDOCUMENTED list holds the backlog that existed when the gate was added. It may only shrink: describing a listed field fails the test until the entry is removed.

Lint warning: when a POST, PUT, or PATCH route has no resolvable request body, ToolDeriver emits a warning via error_log() (or the injected $warn closure). This surfaces missing schema coverage at derivation time rather than silently producing a parameter-free tool.

ToolDeriver stores the merged declarations list, the derived tools list, and the RBAC access map in three static properties ($declarationsCache, $toolsCache, $accessMapCache). These are populated on the first call and reused for the lifetime of the FrankenPHP worker process (WC-951d99d3), so the Router is queried at most once per worker.

Call ToolDeriver::clearCache() after registering plugin routes to ensure the next tools/list or tools/call request picks up the freshly registered tools.


Search and call (the default since WHIT-750)

Section titled “Search and call (the default since WHIT-750)”

Core alone derives over 300 operations, about 150 KB of definitions every agent would load before doing anything, and every plugin adds more. Several clients handle far fewer, and agents pick tools less reliably past about 50 (WHIT-707). So a client connecting to /mcp gets init_session (WHIT-794: call it first; it briefs the model on who it acts as, which areas it can reach, what needs a person’s approval, how to work here and its limits, built from the same filter as tools/list), a handful of everyday read-only tools (my inbox, get a document, list templates, search records), and two tools that reach everything else:

Tool Does
search_operations {query?, toolset?, operationId?, limit?}. With a query: matches by what they do, each with operationId, title, a short summary, toolset, whether it reads or deletes, and its agent policy. With an empty query: the areas (toolsets) the caller can search. With operationId: that one operation in full, including its input schema.
call_operation Calls one operation: {operationId, arguments}. Refusals and unknown names come back as tool errors the model can read.
URL tools/list returns
/mcp the always-on tools, then search_operations and call_operation
/mcp?tools=full every operation the caller may call (the list before WHIT-750)
/mcp?tools=discover only search_operations and call_operation
?toolsets=core.forms,acme.* limits the list and search to those toolsets, in any mode (calls are not limited; the token’s own toolset: scope is what restricts)

The bare URL’s mode is one constant, ToolSurfaceRequest::BARE_MODE. Calling an operation by its own name with tools/call works in every mode. The WHIT-707 tools search_tools, describe_tool and call_tool still answer, unlisted, for one minor release.

Search folds Arabic spellings and matches area titles in Arabic. Its ranking is held by McpSearchEvalTest: about 40 everyday requests, each of which must find its operation in the top three.

Nothing here decides access. Search reads the list through ToolsListHandler::listFor(), the same filter tools/list applies (exposure, token toolsets and permissions, RBAC), so an operation the caller couldn’t call can’t be found either. Hidden and unknown names answer alike. call_operation hands the call to the real ToolsCallHandler, which re-checks everything and audits it.

The transport picks a separate Dispatcher per mode, so the mode never sits in worker-global state. Resources, prompts, auth, the tenant switch and rate limits are identical. The search-and-call lists change only when the always-on set does, so those dispatchers announce no list_changed.

Every operation belongs to one toolset: core.<area> for core (core.forms, core.documents), <pluginKey>[.<sub>] for a plugin. Toolsets organise search results, token scope and splits. They never grant access: that stays with each operation’s permission or role.

A tool’s _meta says where it sits: dev.whity/toolset, dev.whity/visibility, dev.whity/agentPolicy and, when it can be undone, dev.whity/undoWindowSeconds. Clients ignore keys they don’t know.

Visibility Means
always-on Listed directly at /mcp. Core picks its own; a plugin’s tool is listed only once a workspace’s administrator turns it on.
discoverable The default. Found through search_operations.
off Not offered.

Plugins declare these on a tool descriptor (toolset, visibility, agentPolicy, undo) and name their toolsets with PluginMcpToolsetsInterface (SDK 1.52).

What Limit Enforced
Operation name [A-Za-z0-9_-]{1,64} Authored tools are refused at registration; derived names by CI
Description 500 characters Authored tools are refused; derived core by CI; derived plugin tools warn at load
inputSchema 8 KB encoded A warning at load; CI for core
Plugin always-on tools 1 per plugin, read-only, turned on by an administrator Extras are demoted with a warning at load
The list at /mcp 12 tools and 10 KB encoded At runtime, per workspace. Past the cap, plugin tools drop out of the list and stay searchable.
?tools=full None Opt-in

A split serves one slice of the server at its own URL, with the same auth, RBAC, audit and rate limits. It is for narrow clients and hard boundaries; /mcp stays the recommended connection. A split runs search and call too, and what is outside it doesn’t exist there: search can’t find it and calling it answers “unknown tool”. ?toolsets= narrows a list; a split is a boundary.

Source URL
Core areas /mcp/documents, forms, convening, people, inbox, admin, billing, security, platform
A plugin’s toolset /mcp/<toolset id>, or /mcp/x-<id> when the id collides with a reserved name
Composite, named by the operator MCP_SPLITS=sales=core.forms,acme.*;ops=core.time
A workspace agent /mcp/agents/<slug>

GET /api/mcp/splits lists the splits the signed-in person can reach, with their URLs. Each split is its own OAuth resource, with Protected Resource Metadata at /.well-known/oauth-protected-resource/mcp/<split>. A token whose aud names a split works only there; a token for /mcp works on every split.

A person’s own client acts as that person. An agent (principal kind ai-agent) acts for a person under a grant: a core agent credential, a workspace agent, or a Whity ID client an operator marked as an agent (whity_agent: true on its token).

For each permission a grant says Off, Ask first or Allowed. New grants start with reads Allowed and writes Ask first, and an agent can never do more than its person.

  • Ask first files a proposal instead of running: tools/call returns {proposal: {id, status: "waiting", ...}}. The person approves, edits or declines it at /api/me/agent-proposals; only they can, and it expires after 7 days. The agent follows it with get_proposal.
  • A write with no undo is always Ask first, whatever the grant says. An operation that declares an undo may be Allowed.
  • Undo. An operation declares undo (windowSeconds, the reversing operation, its arguments as JSON Pointers into {arguments, result, before}, and optionally a read). What the agent runs is recorded, and its person can undo it inside the window with POST /api/me/agent-actions/{id}/undo, as themselves. It is refused if what it changed has changed since. So far: creating and editing tags, tag groups and forms, for 30 minutes.

The approval and undo routes are never on the MCP surface: an agent can’t approve its own proposal or undo what it did. See docs/design/MCP-TOOL-SURFACE.md §5.11.

The connect wizard hands out the plain /mcp. Loading every action at once (?tools=full) is an opt-in, and it warns when the access chosen exposes more tools than the app is reported to handle (40 for Cursor) or more than about 60 for any app.

Every call to POST /mcp must carry an MCP bearer token:

Authorization: Bearer <mcp-token>

The Dispatcher checks auth before JSON parsing, so an unauthenticated caller learns nothing about the request shape. It gets HTTP 401 with WWW-Authenticate and the UNAUTHENTICATED (-32001) JSON-RPC error.

Apps such as Claude and ChatGPT connect by pasting the URL and signing in, which needs OAuth. whity-core is the protected resource; the authorization server is configuration:

  • MCP_OAUTH_ISSUER names it (default plan: Whity-Auth). Unset means OAuth is off.
  • With it set (and APP_URL known), /.well-known/oauth-protected-resource and /.well-known/oauth-protected-resource/mcp serve RFC 9728 metadata. Fields: resource = APP_URL/mcp, authorization_servers = the issuer, scopes_supported = the MCP capabilities, bearer_methods_supported = header.
  • The 401 challenge names that metadata: Bearer resource_metadata="…/.well-known/oauth-protected-resource/mcp", scope="tools:call resources:read prompts:read".
  • The web tier forwards both documents when the bundled admin serves the public origin.

The access tokens it issues are accepted on /mcp alongside MCP tokens (OAuthAccessTokenVerifier):

  • Signature: firebase/php-jwt against the issuer’s JWKS. The JWKS location comes from MCP_OAUTH_JWKS_URI, or from the issuer’s RFC 8414 metadata, falling back to OIDC discovery; either document’s issuer must match. Keys are cached and refreshed once when a token names an unknown kid. Only asymmetric keys (RSA, EC, OKP) are used.
  • Claims: exp is required, iss must equal the issuer, and aud must include APP_URL/mcp. A token for another resource is refused.
  • Who: (iss, sub) is looked up in the global-trust links of external_identities, the table single sign-on keeps. So the issuer must also be configured as an operator (system-tenant) identity provider, and each person signs in to Whity with it once. A tenant’s own identity provider never answers here.
  • Which workspace: the tenant_id claim (renamed with MCP_OAUTH_TENANT_CLAIM) when present, else the person’s only active workspace. An active membership is always required. The system tenant cannot be reached with an OAuth token.
  • Scope: scope (or scp), keeping only tools:call, resources:read and prompts:read.
  • Answers: a token that fails verification gets the usual 401, so the app signs in again. A genuine token that can’t be used (no linked account, several workspaces and no claim, no MCP scope) gets 403 with a message that says what to fix, because signing in again would not help.

The principal’s principalKind is oauth; RBAC, the per-tenant MCP switch, rate limits and audit apply exactly as for an MCP token.

Section titled “Workspace lookup for the consent screen (WHIT-802)”

To let a person pick the workspace that goes into the access token as tenant_id, the authorization server asks this instance which workspaces they belong to: GET /api/v1/oauth/workspaces (OAuthWorkspacesHandler).

  • Assertion: Authorization: Bearer <JWT>, signed by the authorization server with a key from its published JWKS and verified exactly like an access token (IssuerJwtVerifier, shared with OAuthAccessTokenVerifier; asymmetric keys only). The header typ must be whity-workspaces+jwt, so an access token (at+jwt) can’t be replayed here. iss must equal MCP_OAUTH_ISSUER, aud must be exactly APP_URL/api/v1/oauth/workspaces (a string, or an array containing it), exp is required and at most 120 seconds after iat, and sub is the person at the authorization server.
  • Who: (iss, sub) through the global-trust links of external_identities, as for access tokens. A tenant identity provider’s link never resolves.
  • Answer (200): {"linked": true, "workspaces": [{"id": 3, "name": "Example Bakery", "slug": "example-bakery"}]}. Only active memberships, never the system tenant, in workspace id order; slug may be null. An unlinked subject gets {"linked": false, "workspaces": []}, so the consent screen can tell the person to sign in to Whity once first.
  • Errors: a missing, invalid or expired assertion gets a bare 401 with no detail. With OAuth off (MCP_OAUTH_ISSUER or APP_URL unset) the route answers 404, like the metadata documents.
  • Access: machine to machine. There is no user RBAC; the authorization server’s signing key is the authorisation. The route skips tenant resolution (no workspace is chosen yet), is covered by the kernel’s per-IP rate limit, and is never an MCP tool ('mcp' => ['expose' => false]).

MCP tokens are 90-day HS256 JWTs with aud: mcp and type: mcp. They are issued, listed, and revoked through a dedicated REST sub-API:

Method Path Action
POST /api/mcp/tokens Issue a new MCP token
GET /api/mcp/tokens List active tokens for the current user
DELETE /api/mcp/tokens/{jti} Revoke a token by JTI

These endpoints require a valid human-user access token (cookie or Bearer). The issued MCP tokens are then used as Bearer tokens on POST /mcp for machine-to-machine calls from AI clients. See src/Mcp/Auth/McpTokenHandler.php and src/Mcp/Auth/McpTokenService.php.

Token JTIs are tracked in the mcp_tokens table. Revocation inserts the JTI into the shared revoked_tokens table, consistent with access/refresh token revocation.

TokenValidator::validateMcpToken() returns an McpPrincipal on success (src/Mcp/Auth/McpPrincipal.php):

Field Type Description
userId int User the token was issued to
tenantId int Tenant the token is scoped to
principalKind string Principal kind (user for current phase)
scope string[] Granted scopes, e.g. ['tools:call']. Interpreted by McpScope: capabilities open method families, permission names narrow RBAC (ADR-0006, Amendment 1)
jti string JWT ID — unique revocation handle

McpPrincipal is immutable and carries no static/global state, making it worker-safe by construction.


After token validation the Dispatcher calls TenantContext::setTenantId($principal->tenantId), locking the worker thread’s tenant context to the principal’s tenant for the duration of the request. TenantContext is a request-scoped static holder (see src/Core/Tenant/TenantContext.php).

TenantContext::reset() is called in the Dispatcher’s finally block, guaranteeing that tenant state is cleared even when McpRateLimitException propagates out. This mirrors the pattern used by the HTTP kernel and the worker loop to prevent tenant bleed across FrankenPHP persistent-worker requests.

The per-tenant MCP opt-in closure ($tenantMcpEnabled) is evaluated inside the same try block, after setTenantId() and before rate limiting. When the closure returns false, McpFeatureDisabledException is thrown and caught by McpTransportHandler, which returns HTTP 403.


Route declarations carry requiredRole and requiredPermission keys. ToolDeriver::buildAccessMap() reads these from the merged declarations at derivation time and builds a map of toolName → {requiredRole, requiredPermission}.

ToolsListHandler (src/Mcp/Tools/ToolsListHandler.php) filters the tool list so callers only see tools they are permitted to use:

  • Tools with no requiredRole and no requiredPermission are visible to all callers including unauthenticated ones.
  • Protected tools are hidden when the bearer token is absent or invalid, or when RoleChecker denies the required grant.

Filtering is soft-auth: a missing or invalid token never throws; it simply restricts the visible set to open tools. RBAC is still hard-enforced in ToolsCallHandler when a tool is invoked.

ToolsCallHandler (src/Mcp/Tools/ToolsCallHandler.php) re-validates the bearer token to obtain the principal, then enforces the matched route’s access controls via RoleChecker — the same component the HTTP RbacMiddleware uses, so MCP RBAC can never diverge from HTTP authorization:

  1. requiredPermission is checked first via RoleChecker::hasPermission().
  2. When only requiredRole is set, RoleChecker::hasRole() is checked.
  3. On denial, McpException(ErrorCode::FORBIDDEN) is thrown.

The authorization check reads access controls from the live matched route (the result of Router::match()), not solely from the declaration cache, so a plugin that changes its route’s access requirements after worker boot is covered on the next request after clearCache().


McpRateLimiter (src/Mcp/RateLimit/McpRateLimiter.php) enforces two independent fixed-window budgets, checked after authentication and before JSON parsing:

Counter Key Default limit Window
Tenant mcp:rate:tenant:{tenantId} 300 calls 60 seconds
Principal mcp:rate:principal:{userId} 60 calls 60 seconds

Both counters are incremented atomically via SharedStoreInterface::increment() (the same interface LoginThrottleService uses). The tenant counter is checked first. When either limit is exceeded McpRateLimitException is thrown with a retryAfterSeconds value; McpTransportHandler maps this to HTTP 429 with a Retry-After header.

Fixed-window semantics: the TTL is set once on the first call in the window and never extended. The window resets atomically when the TTL elapses — no manual cleanup pass is needed.


Dispatcher::dispatch() catches all \Throwable from method handlers and returns ErrorCode::INTERNAL_ERROR with the fixed message "Internal error" — handler exception messages are never forwarded to the caller. Only McpException (thrown explicitly by method handlers for protocol-level errors) carries a caller-visible message, and those messages are intentionally minimal.

TenantContext::setTenantId() locks the context after the first call. Any subsequent attempt to set a different tenant within the same request throws a RuntimeException. TenantContext::reset() in the Dispatcher’s finally block clears the lock, preventing tenant state from persisting across requests on a shared FrankenPHP worker.

ToolsCallHandler calls AuditContext::set($principal->userId, null) before invoking the tool, routing the AI principal’s identity into the audit context. Any hook-fired audit entries written by mutation tools (e.g. user.created) therefore record the MCP actor rather than null. AuditContext is reset between requests by HttpKernel::resetRequestState() (WC-181) and the worker loop’s finally block.

ToolsCallHandler records an mcp.tools.call audit entry in a finally block that wraps the entire tool execution, so the entry is written whether the call succeeds, fails RBAC, or encounters an internal error:

{
"action": "mcp.tools.call",
"tenant_id": 42,
"actor_user_id": 7,
"target_type": "tool",
"metadata": {
"tool": "post_api_users",
"args": { "email": "alice@example.com" }
}
}

Arguments are redacted before storage: any key whose name contains password, secret, token, code, hash, backup_code, or two_factor_secret is stripped. This matches the redaction logic in AuditLogger (defense-in-depth).

Arguments that fail the tool’s inputSchema come back as a tool result with isError: true, not as JSON-RPC -32602, because MCP classes input validation errors as tool execution errors so the model can correct its call (WHIT-710). The text names the problem and lists the expected arguments:

Invalid arguments for post_api_users: Missing required argument: email. Expected: email (string, required), name (string).

The route handler never runs and the call is audited as before. An unknown tool name is still -32601 at the top level. Through call_operation, an unknown name returns a tool error pointing at search_operations.

When a route handler throws inside ToolsCallHandler::executeResolved(), the exception is caught and isError: true content with the message "Internal error" is returned as an MCP result — the caller knows the tool failed but learns nothing about the underlying cause.


ResourceDeriver (src/Mcp/Resources/ResourceDeriver.php) derives MCP resources from GET route declarations:

  • Routes without path parameters become static resources (listed under resources).
  • Routes with path parameters become resource templates (listed under resourceTemplates).

URI scheme: whity-api:///api/v1/path. Routing constraints are stripped from URI templates to produce RFC 6570-compliant {id} placeholders.

ResourceDeriver is stateless — all computation is per-call on the stack, so there is no worker-boot cache to clear.


Built-in prompts are registered via CorePrompts::register() (src/Mcp/Prompts/CorePrompts.php) at worker boot and stored in PromptRegistry. Each prompt has an optional requiredRole or requiredPermission; PromptsListHandler filters accordingly. The four built-in prompts are:

Name Access Purpose
onboarding-walkthrough open Initial tenant setup guide
role-audit admin role RBAC configuration audit
relation-query relations:read Relation graph exploration
permission-summary users:read Per-user effective permissions

The endpoint serves every MCP version from 2025-03-26 to 2026-07-28 (WHIT-708). Both eras share one endpoint and need no per-connection memory. McpProtocol (src/Mcp/Protocol/McpProtocol.php) holds the version list and what each version changes.

The Dispatcher reads it from each request, never from an earlier one:

  1. A version in params._meta["io.modelcontextprotocol/protocolVersion"] makes it a 2026-07-28 request. It must be a version this server speaks, and its headers must match the body: MCP-Protocol-Version, Mcp-Method, and Mcp-Name for tools/call, prompts/get and resources/read (a =?base64?…?= value is decoded first). Otherwise the request gets HTTP 400 with HeaderMismatch (-32020) or UnsupportedProtocolVersion (-32022, data.supported lists the versions).
  2. Otherwise the MCP-Protocol-Version header names a legacy version (2025-06-18 or 2025-11-25), as negotiated by initialize.
  3. A request with neither speaks 2025-03-26, whose clients send no header.

The version is then written into the request’s params._meta, where every handler can read it.

initialize answers with the client’s requested version when the server speaks it, else the newest legacy version (2025-11-25):

{
"protocolVersion": "2025-06-18",
"capabilities": {
"tools": { "listChanged": true },
"resources": { "subscribe": false, "listChanged": true },
"prompts": { "listChanged": true }
},
"serverInfo": { "name": "whity-core", "version": "<CoreVersion>" }
}

Batches work only on 2025-03-26, since 2025-06-18 removed them. A batch sent with a later version header gets 400. A notification gets 202 Accepted with no body.

From Added
2025-06-18 Tools carry a top-level title (also kept in annotations.title). A tools/call result whose text is a JSON object also carries it as structuredContent; the text block stays.
2026-07-28 Every result has resultType: "complete" and _meta["io.modelcontextprotocol/serverInfo"]. List results and resources/read carry ttlMs: 60000 and cacheScope: "private". structuredContent may be any JSON value. Resource-not-found is -32602. initialize, ping and logging/setLevel don’t exist (404). server/discover returns the supported versions, capabilities and identity.

No outputSchema is declared. A declared schema obliges every result to conform, and the route response schemas aren’t yet checked against what handlers return (WHIT-120). Clients that validate would reject correct data.

No subscriptions/listen. It holds a response stream open indefinitely, and under FrankenPHP that pins a worker; this is also why GET /mcp answers 405. A 2026 client is therefore offered no listChanged, gets no list_changed pushes, and refreshes lists using ttlMs. Legacy clients keep their notifications on POST responses, as before.


MCP clients cache the discovery lists at connection time. A client that connected before a plugin rebuild used to keep its stale tool definitions indefinitely — which is how records were once written double-encoded against a server that had been serving the corrected schema all along (#952).

The server now emits the spec’s list-change notifications:

Notification Sent when
notifications/tools/list_changed the derived tool catalogue differs from the one this client was last told about
notifications/resources/list_changed likewise for the resource catalogue
notifications/prompts/list_changed likewise for the prompt catalogue

How it is decided. The signal is content-derived, not event-driven: each worker hashes the catalogue it would actually serve (src/Mcp/Notifications/CatalogSignature.php) and compares that against what the calling client has already been told. This is what makes it safe across the worker pool — a worker that has not yet picked up a plugin change computes the old signature and stays silent, so no client is ever told about a catalogue the answering worker cannot then serve. The “already told” marker lives in the shared store, so one change produces one notification per client rather than one per worker.

How it is delivered. On the client’s next POST /mcp, provided the client advertised Accept: text/event-stream (the MCP spec requires conformant clients to). The response becomes an SSE body carrying the notification frames first and the JSON-RPC response last. A client that did not offer to read an event stream is left alone and keeps receiving plain JSON.

What it costs when nobody is listening. Nothing. There are no connections to push to and no work is done on the reload path; a reload that no client ever follows up on produces no notification and no error.

What fires it. Any change to the content of a served list: a plugin install, update, or uninstall; an administrative enable or disable; a POST /api/plugins/reload that finds a disk change; the per-request hot reload in development; and a deploy that changes core routes. Reverts are announced too — an install followed by an uninstall announces twice, because a client told about the second catalogue has its record of the first retired.

What does not fire it. A worker boot on an unchanged catalogue (otherwise eight workers would announce eight times); a reload that finds no disk change; ordinary requests; and a plugin tripping the runtime error boundary, which leaves its declarations registered and short-circuited — its behaviour changed, its tool definitions did not. A caller whose permissions changed also sees a different tools/list without any notification: that is a grant change, not a registry change.

Bound. The shared store is a counter store with no value semantics, so what is recorded is “this client has been told about this catalogue”, not “the catalogue I last served”. Retiring a client’s stale records on the way past covers reverts within what a worker has observed (CatalogSignature::RECENT_LIMIT, the last 8 catalogues per list); beyond that a record expires on its own after ListChangedNotifier::SEEN_TTL_SECONDS (24h). The failure mode at expiry is one redundant tools/list round trip, which is the direction a correctness fix should fail in.


Defined in src/Mcp/JsonRpc/ErrorCode.php:

Constant Value Meaning
PARSE_ERROR -32700 JSON-RPC 2.0: the body could not be parsed as JSON
INVALID_REQUEST -32600 JSON-RPC 2.0: the request object is structurally invalid
METHOD_NOT_FOUND -32601 JSON-RPC 2.0: unknown method name
INVALID_PARAMS -32602 JSON-RPC 2.0: method-specific parameter validation failed
INTERNAL_ERROR -32603 JSON-RPC 2.0: unhandled server-side error (no details exposed)
RATE_LIMITED -32000 MCP: call budget exhausted (HTTP 429)
UNAUTHENTICATED -32001 MCP: bearer token absent or invalid (HTTP 401)
RESOURCE_NOT_FOUND -32002 MCP: referenced resource URI not found
FORBIDDEN -32003 MCP: caller does not have the required role/permission