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.
Architecture
Section titled “Architecture”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 streamThere 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 → CancelledNotificationHandlerMcpTransportHandler (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.
Tool derivation
Section titled “Tool derivation”ToolDeriver (src/Mcp/Tools/ToolDeriver.php) converts route declarations into MCP tool definitions at tools/list call time. It reads from two sources:
- Static declarations — passed to the constructor as
$staticDeclarations(the core API route list). - Router-native routes — the
Routeris queried at derivation time (not at construction), so plugin routes registered afterToolDeriveris 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.
operationId (tool name)
Section titled “operationId (tool name)”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 |
Tool description
Section titled “Tool description”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"].
inputSchema construction
Section titled “inputSchema construction”buildInputSchema() assembles a flat JSON Schema object by merging three sources in order:
-
Path parameters — extracted from
{name}or{name:constraint}segments of the route path. Always required. Type isintegerwhen the constraint is\d+or[0-9]+;stringotherwise. Each is described: by the route’s ownin: pathentry inschema['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.”). -
Query parameters — declared in
schema['parameters']within: query. Optional unlessrequired: true. Each parameter’s type and description come from itsschemasub-key. -
Request body — resolved from
schema['request'], which may be:- a string component name: looked up in the global
$componentsmap first, then inschema['components']on the route declaration itself. - an inline schema array: merged directly (bodies with a
contentkey are skipped as they represent multipart or custom content types that cannot be expressed as a flat object).
The resolved body schema’s
propertiesandrequiredarrays are merged into the accumulator. - a string component name: looked up in the global
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.
Worker-boot cache
Section titled “Worker-boot cache”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.
Toolsets and visibility
Section titled “Toolsets and visibility”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).
Budgets
Section titled “Budgets”| 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 |
Splits
Section titled “Splits”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.
Agents
Section titled “Agents”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/callreturns{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 withget_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 reversingoperation, itsargumentsas JSON Pointers into{arguments, result, before}, and optionally aread). What the agent runs is recorded, and its person can undo it inside the window withPOST /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.
Authentication
Section titled “Authentication”Bearer tokens
Section titled “Bearer tokens”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.
OAuth sign-in (ADR-0015)
Section titled “OAuth sign-in (ADR-0015)”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_ISSUERnames it (default plan: Whity-Auth). Unset means OAuth is off.- With it set (and
APP_URLknown),/.well-known/oauth-protected-resourceand/.well-known/oauth-protected-resource/mcpserve 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’sissuermust match. Keys are cached and refreshed once when a token names an unknownkid. Only asymmetric keys (RSA, EC, OKP) are used. - Claims:
expis required,issmust equal the issuer, andaudmust includeAPP_URL/mcp. A token for another resource is refused. - Who:
(iss, sub)is looked up in the global-trust links ofexternal_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_idclaim (renamed withMCP_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(orscp), keeping onlytools:call,resources:readandprompts: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.
Workspace lookup for the consent screen (WHIT-802)
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 withOAuthAccessTokenVerifier; asymmetric keys only). The headertypmust bewhity-workspaces+jwt, so an access token (at+jwt) can’t be replayed here.issmust equalMCP_OAUTH_ISSUER,audmust be exactlyAPP_URL/api/v1/oauth/workspaces(a string, or an array containing it),expis required and at most 120 seconds afteriat, andsubis the person at the authorization server. - Who:
(iss, sub)through the global-trust links ofexternal_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;slugmay benull. 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_ISSUERorAPP_URLunset) 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.
McpPrincipal
Section titled “McpPrincipal”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.
Tenant scoping
Section titled “Tenant scoping”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}.
tools/list filtering
Section titled “tools/list filtering”ToolsListHandler (src/Mcp/Tools/ToolsListHandler.php) filters the tool list so callers only see tools they are permitted to use:
- Tools with no
requiredRoleand norequiredPermissionare visible to all callers including unauthenticated ones. - Protected tools are hidden when the bearer token is absent or invalid, or when
RoleCheckerdenies 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.
tools/call enforcement
Section titled “tools/call enforcement”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:
requiredPermissionis checked first viaRoleChecker::hasPermission().- When only
requiredRoleis set,RoleChecker::hasRole()is checked. - 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().
Rate limiting
Section titled “Rate limiting”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.
Security model
Section titled “Security model”Exception message isolation
Section titled “Exception message isolation”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.
Tenant bleed prevention
Section titled “Tenant bleed prevention”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.
AuditContext
Section titled “AuditContext”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.
Audit logging of tool calls
Section titled “Audit logging of tool calls”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).
Argument errors
Section titled “Argument errors”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.
Application error containment
Section titled “Application error containment”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.
Resources
Section titled “Resources”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.
Prompts
Section titled “Prompts”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 |
Protocol
Section titled “Protocol”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.
Which version a request speaks
Section titled “Which version a request speaks”The Dispatcher reads it from each request, never from an earlier one:
- 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, andMcp-Namefortools/call,prompts/getandresources/read(a=?base64?…?=value is decoded first). Otherwise the request gets HTTP 400 withHeaderMismatch(-32020) orUnsupportedProtocolVersion(-32022,data.supportedlists the versions). - Otherwise the
MCP-Protocol-Versionheader names a legacy version (2025-06-18 or 2025-11-25), as negotiated byinitialize. - 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.
Legacy versions: initialize
Section titled “Legacy versions: initialize”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.
What each version adds to a response
Section titled “What each version adds to a response”| 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.
Change notifications
Section titled “Change notifications”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.
Error codes
Section titled “Error codes”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 |