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.
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. -
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.
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.
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 (UNAUTHENTICATED error code -32001 is returned immediately).
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'] |
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).
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 initialize response declares the server’s capabilities and protocol version:
{ "protocolVersion": "2025-03-26", "capabilities": { "tools": { "listChanged": true }, "resources": { "subscribe": false, "listChanged": true }, "prompts": { "listChanged": true } }, "serverInfo": { "name": "whity-core", "version": "1.0" }}Batch requests are supported: a JSON array of request objects returns a JSON array of responses. Notifications (objects without an id member) are processed but produce no response.
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 |