Skip to content

MCP Operator Runbook

Operational guide for administrators managing the MCP endpoint on a running Whity Core instance.

Related: MCP-Server · MCP-Client-Connection · AUDIT_TRAIL · PERMISSION_SYSTEM.


MCP access is controlled by a per-tenant opt-in check. The Dispatcher receives a $tenantMcpEnabled closure at construction time; when this closure returns false for a tenant ID, every request from that tenant receives HTTP 403.

The closure is wired in public/index.php. Enabling or disabling MCP for a tenant requires updating the underlying data source that the closure queries (a tenant settings table, an environment variable, or a feature flag store, depending on your deployment). Consult your deployment configuration.

When a tenant is not opted in, the HTTP response is a bare 403 with no body. The JSON-RPC response is never reached, and nothing is written to the audit log.


An AI principal is a user-scoped MCP token. Tokens are managed via the REST sub-API (requires a valid human-user access token):

Terminal window
curl -s -X POST https://your-whity-host/api/mcp/tokens \
-H "Authorization: Bearer <admin-access-token>" \
-H "Content-Type: application/json" \
-d '{"name": "automation-agent", "scope": ["tools:call"]}'

The response body contains the raw token string. Store it securely — it is only returned once.

A token’s scope decides what it can do. It never grants more than the issuing user’s role allows (ADR-0006, Amendment 1).

Capabilities. Name at least one; issuance refuses a scope without any.

Capability Opens
tools:call tools/list, tools/call
resources:read resources/list, resources/templates/list, resources/read
prompts:read prompts/list, prompts/get

A method outside the token’s capabilities answers -32003 Forbidden.

Narrowing (optional). Add permission names to restrict the token to what they gate:

{"name": "inbox-triage", "scope": ["tools:call", "documents:read", "documents:route"]}
{"name": "user-sync", "scope": ["tools:call", "resources:read", "users:*"]}
  • users:* covers every permission of the users resource.
  • A narrowed token loses everything no named permission gates: role-only tools and the opted-in self-service tools (inbox, notifications).
  • An unknown permission name is refused at issuance with 422, so a typo cannot silently narrow a token to nothing.

Prefer a narrowed token for any automation that does one job. If it leaks, it can do that job and nothing else.

Toolsets (optional). toolset:core.forms or toolset:acme.* limits the token to those areas, on both the list and the call. They narrow further and never grant anything.

/mcp lists at most 12 tools and 10 KB, per workspace. The MCP Tools admin page shows each operation’s toolset, how apps see it and what agents may do with it, plus what each kind of connection costs an app in tokens.

A plugin may ask for one read-only tool to be listed directly. It stays searchable-only until an administrator turns it on, with the switch on that page (the setting mcp.always_on_plugin_tools). Past the cap, the extra plugin tools drop out of the list and stay searchable, so a turned-on tool that isn’t listed is not an error.

/mcp/<split> serves one slice of the server. Core’s areas and each plugin’s toolset are there already. Name your own with MCP_SPLITS, for example:

MCP_SPLITS=sales=core.forms,acme.*;ops=core.time

A name that collides with a core area or agents is ignored. Rate limits are per principal across splits, and the audit trail records the split.

An agent’s writes wait for its person’s approval unless its grant says Allowed and the operation can be undone. Watch these audit actions: agent.proposal.approved, agent.proposal.declined and agent.action.undone. A proposal expires after 7 days. Undo needs nothing from an operator.

Older tokens. Tokens issued before scopes were enforced keep working:

  • One with no recognized capability keeps every family.
  • Spellings such as tools:list or prompts:get count as their family’s capability.

Reissue a token to narrow it; a scope cannot be edited in place.

Terminal window
curl -s https://your-whity-host/api/mcp/tokens \
-H "Authorization: Bearer <access-token>"

Returns all non-expired, non-revoked tokens for the authenticated user. The raw token value is not included in the listing — only the jti, name, scope, principal_kind, expires_at, and created_at.

Terminal window
curl -s -X DELETE https://your-whity-host/api/mcp/tokens/<jti> \
-H "Authorization: Bearer <access-token>"

Returns 204 on success. Returns 404 when the JTI does not exist or belongs to a different user/tenant. Revocation is immediate: the JTI is inserted into the shared revoked_tokens table, which is checked on every subsequent token validation.

Tokens expire automatically after 90 days (McpTokenService::TOKEN_LIFETIME_SECONDS = 7_776_000). Expired tokens fail validation even if not explicitly revoked.


McpRateLimiter defaults are set at construction time in public/index.php:

Limit Default Description
$tenantLimit 300 calls / 60 s Total AI calls from all principals in a tenant per window
$principalLimit 60 calls / 60 s Calls from one principal per window

To change these values, adjust the arguments passed to the McpRateLimiter constructor in public/index.php. Limits are per-worker; in a multi-worker deployment each worker maintains its own counters via the shared store (SharedStoreInterface).

When a limit is hit, the client receives HTTP 429 with a Retry-After: 60 header. The counter decays naturally when the 60-second window expires; no manual reset is needed.


Every tools/call invocation is recorded in the audit_log table under the action mcp.tools.call, regardless of outcome (success, RBAC denial, or handler error). The entry is written in a finally block so it is never omitted.

Query recent MCP activity for a tenant (requires audit:read permission):

Terminal window
curl -s "https://your-whity-host/api/audit-log?action=mcp.tools.call" \
-H "Authorization: Bearer <admin-access-token>"

Example audit log entry:

{
"id": 1234,
"tenant_id": 42,
"actor_user_id": 7,
"action": "mcp.tools.call",
"target_type": "tool",
"target_id": null,
"metadata": {
"tool": "post_api_users",
"args": { "email": "alice@example.com" }
},
"ip_address": null,
"created_at": "2026-06-29T14:00:00"
}

Notes:

  • actor_user_id is the userId from the MCP principal — the user who owns the token, not a human operator.
  • ip_address is null for MCP calls (the call is machine-originated; there is no client IP in the MCP request).
  • metadata.args is redacted: keys containing password, secret, token, code, hash, backup_code, or two_factor_secret are stripped before the entry is written.
Condition Signal Action
High mcp.tools.call volume from a single actor_user_id Possible runaway agent Review the token, consider revocation
Repeated FORBIDDEN errors from the same actor_user_id Agent attempting over-privileged calls, or calls outside its token’s scope Review the token’s scope (capabilities and narrowing) and the user’s role
An agent says a tool does not exist that the REST API has The route has no permission or role gate, so it is not offered to agents Expected for session, credential and public routes. For a self-authorizing route, opt it in ('mcp' => true, ADR-0006 Amendment 1)
Sustained 429 responses Rate limit too low or excessive agent traffic Raise limits or revoke token
mcp.tools.call from an unexpected tenant_id Token leak or misconfiguration Immediately revoke the affected token

The bearer token is absent or invalid.

Checklist:

  1. Confirm the Authorization: Bearer <token> header is being sent.
  2. Confirm the token has not expired (90-day lifetime; check expires_at from the issue response).
  3. Confirm the token has not been revoked (GET /api/mcp/tokens lists active tokens).
  4. Confirm the token was issued with aud: mcp — human access tokens do not work on /mcp.

The tenant or principal call budget for the current 60-second window is exhausted.

Resolution: Wait for the window to expire (indicated by the Retry-After response header), then retry. If the limit is consistently hit, consider raising the $tenantLimit or $principalLimit in public/index.php, or reducing the agent’s call frequency.

The requesting tenant has not opted in to MCP.

Resolution: Enable MCP for the tenant via your deployment’s feature flag or configuration mechanism (see “Enabling MCP per tenant” above).

Content-Type: application/json was not sent with the request.

Resolution: Add Content-Type: application/json to every POST /mcp request.

The named tool was not found in the derived tool list.

Possible causes:

  1. Typo in the tool name. Call tools/list to enumerate available tools and confirm the exact name.
  2. Plugin route not yet reflected. If a plugin was loaded after the worker booted, ToolDeriver::clearCache() must have been called to invalidate the worker-boot cache. The tool will appear in tools/list on the next request after the cache is cleared.
  3. Route has no schema. Routes without a schema array are excluded from tool derivation. Add a schema to the route declaration.

The caller’s MCP principal does not hold the required role or permission for the tool.

Resolution:

  1. Call tools/list — protected tools the caller cannot use are filtered from the list entirely. If the tool is absent, the principal lacks access.
  2. Check the route declaration’s requiredRole / requiredPermission.
  3. Assign the required role or permission to the user account that owns the MCP token (via the standard RBAC API).

The underlying HTTP route handler returned a 4xx or 5xx response. The tool was successfully routed and authorized; the error originates in the handler.

Resolution: Inspect content[0].text in the response — it contains the handler’s raw response body. For 500s the text will be "Internal error" (the real exception is not exposed); check the application error log for the underlying cause.