About
mcp-test-server is a configurable mock MCP server built as a test fixture — not a real MCP. It speaks the protocol like any other server, but exposes an admin REST API and a dashboard so test code can mutate tools/prompts/resources, simulate consent flows, and now inject faults — all at runtime.
What it isn't: a production MCP server, a fault toolkit for malformed JSON / SSE drops / connection resets, or a real OAuth provider.
Workspaces
Every tester gets their own isolated workspace at /t/<name>/. Workspaces are lazy-created on first access. State is in-memory; OAuth credentials are deterministically derived from the workspace name (so the same name always gets the same client_id/secret across server restarts).
| URL | Purpose |
|---|---|
/ | Landing page — pick or auto-generate a workspace |
/t/<name>/ | Workspace dashboard (HTML) |
/t/<name>/mcp | MCP JSON-RPC endpoint |
/t/<name>/admin/... | Admin REST API |
/t/<name>/oauth/... | OAuth shim |
Naming rules: 1–64 chars of [a-zA-Z0-9_-]. Reserved: admin, oauth, mcp, public, static, t, api.
When a workspace is deleted via DELETE /admin/tenants/<name>, that name is tombstoned for 60 seconds (returns 410 Gone) so concurrent in-flight requests don't lazy-recreate it.
MCP endpoint
POST /t/<name>/mcp — JSON-RPC 2.0. Accepts application/json or text/event-stream (SSE) responses depending on the Accept header.
| Method | Behavior |
|---|---|
initialize | Returns protocolVersion + advertised capabilities (tools/prompts/resources, gated by state.capabilities) |
tools/list | Paginated list (page size from state.pagination.pageSize) |
tools/call | Looks up the tool by name. Returns the tool's responseText if set, otherwise echoes tool name + arguments. Honors the tool's responseDelayMs and responseIsError. Unknown tool name returns an isError: true response. |
prompts/list | Paginated |
resources/list | Paginated |
Request/response shapes follow the MCP specification.
OAuth metadata endpoints (probed by Okta's MCP integration during metadata detection) are served at the tenant root and at the RFC-strict locations:
GET /t/<name>/.well-known/oauth-protected-resource
GET /t/<name>/.well-known/oauth-authorization-server
GET /t/<name>/.well-known/openid-configuration
GET /.well-known/oauth-protected-resource/t/<name>
GET /.well-known/oauth-authorization-server/t/<name>
GET /.well-known/openid-configuration/t/<name>
OAuth shim
OAuth 2.0 implementation supporting authorization_code + refresh_token grants and RFC 7591 Dynamic Client Registration. Per-workspace credentials (deterministic from the workspace name) plus optional runtime-registered DCR clients.
| Endpoint | Purpose |
|---|---|
GET /oauth/authorize | Redirects with an auth code (or shows consent if enabled) |
POST /oauth/token | Exchange code or refresh token for an access token. Layered client-cred check: dcr_* ids look up in the DCR store, 0oamcp_* ids use the deterministic per-workspace creds. |
POST /oauth/register | RFC 7591 — register a client at runtime; get back client_id (+ client_secret for confidential clients). |
GET /oauth/.well-known/oauth-authorization-server | Authorization server metadata (advertises registration_endpoint) |
Reference: RFC 6749 (OAuth 2.0), RFC 7591 (Dynamic Client Registration), RFC 7636 (PKCE).
Auth trust (external Okta AS)
A workspace can opt in to validating /mcp bearer tokens against one or more external Okta authorization servers instead of (or alongside) the mock's own tokens. Config lives per-workspace under /admin/auth — see below for the modes, config schema, verification flow, error responses, and env-var tunables.
Modes
| Mode | Behavior |
|---|---|
off | Default. No bearer validation on /mcp. Existing perf tests and legacy flows keep working. |
strict | External Okta tokens only. Internal mcp_at_* tokens are rejected 401 invalid_token. |
hybrid | Accepts both. Token starts with mcp_at_ → local verification; otherwise → Okta path (JWT or introspection). |
Admin API
| Endpoint | Purpose |
|---|---|
GET /admin/auth | Read current mode + allowedIssuers[]. client_secret is replaced with client_secret_set: true|false — the plaintext and ciphertext are never returned. |
PATCH /admin/auth | Set mode and/or allowedIssuers[]. Full-replace on allowedIssuers[]. Config-write validation returns 400 with specific reasons on violation. |
DELETE /admin/auth | Clear config (equivalent to mode:'off' + empty allowedIssuers[]). |
Example: configure a workspace with one Okta issuer
Use the dashboard's Auth trust card (recommended) or the admin API directly:
curl -X PATCH "$BASE/t/alice/admin/auth" \
-H 'Content-Type: application/json' \
-d '{
"mode": "strict",
"allowedIssuers": [
{
"issuer_url": "https://<your-org>.okta.com/oauth2/aus1abc...",
"jwks_uri": "https://<your-org>.okta.com/oauth2/aus1abc.../v1/keys",
"introspection_uri": "https://<your-org>.okta.com/oauth2/aus1abc.../v1/introspect",
"audience": "api://mcp-server",
"required_scopes": ["mcp:tools:execute"],
"client_credentials": {
"client_id": "0oa1abc...",
"client_secret": "<paste-secret-from-okta>"
}
}
]
}'
How the client secret is handled
The client_secret field takes the literal secret from Okta. On save, the mock encrypts it with AES-256-GCM using a fleet-wide master key (MCP_SECRET_ENCRYPTION_KEY) and stores only the ciphertext in the workspace's config. On GET /admin/auth, the field is replaced with client_secret_set: true|false — the admin API never returns the value in plaintext or ciphertext form. On subsequent edits, leaving the secret field blank preserves the existing encrypted value; enter a new value to replace it. This lets you tweak audience, required_scopes, etc. without re-typing the secret every time.
If MCP_SECRET_ENCRYPTION_KEY is not set, the mock derives a deterministic default key from a marker string and warns at boot — fine for local dev and CI, do not deploy publicly without a real key. Generate one with openssl rand -base64 32.
Verification flow
- Bearer extracted from
Authorization: Bearer <t>or?access_token=<t>query param (the query-param path exists so EventSource clients onGET /mcpcan present a token). - If
hybridand token starts withmcp_at_→ local deterministic verification; otherwise Okta path. - Okta path: three-dot JWT → verify signature via cached JWKS for the matching
issuer_url, checkexp/nbfwith clock-skew grace, checkaudmatches the entry'saudience, checkrequired_scopesare all present inscope/scp. Non-JWT → introspection viaintrospection_uriwith the configuredclient_credentials.
Error responses (RFC 6750)
| Failure | Status | error | error_description |
|---|---|---|---|
| Missing bearer | 401 | invalid_request | Missing Bearer token in Authorization header |
| Expired | 401 | invalid_token | Token has expired (exp claim) |
| Bad signature | 401 | invalid_token | Signature verification failed against JWKS |
Unknown iss | 401 | invalid_token | Issuer not in allowed_issuers |
Wrong aud | 401 | invalid_token | Audience mismatch: expected '<audience>' |
| Missing required scope | 401 | insufficient_scope | Token missing required scope '<scope>' |
| JWKS fetch failed | 503 | service_unavailable | (generic; check server logs for the issuer) |
Fleet-wide tunables (env vars)
| Env var | Default | Purpose |
|---|---|---|
MCP_SECRET_ENCRYPTION_KEY | deterministic default + boot warning | Base64-encoded 32-byte key for AES-256-GCM encryption of client_secret at rest. |
MCP_JWKS_CACHE_TTL_MS | 600000 (10 min) | JWKS cache TTL. |
MCP_JWKS_KID_MISS_COOLDOWN_MS | 30000 (30 s) | Per-issuer cooldown after a kid-miss refresh. |
MCP_JWT_CLOCK_SKEW_MS | 30000 (30 s) | Grace on exp / nbf. |
Using the mock in DCR mode
Two modes depending on what your test scenario needs:
Mode A — Open registration (default)
Register a client without any prior setup. This is the fastest path when you just want to exercise the RFC 7591 handshake against a fixture.
# Confidential client (server issues client_secret)
curl -X POST $BASE/t/<workspace>/oauth/register -H 'Content-Type: application/json' -d '{
"client_name": "my-agent",
"redirect_uris": ["http://localhost:9898/callback"],
"grant_types": ["authorization_code","refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_post"
}'
# → 201
# {
# "client_id": "dcr_...", ← use this at /oauth/token
# "client_secret": "dcrs_...", ← and this
# "client_id_issued_at": ...,
# "client_secret_expires_at": 0,
# ...
# }
# Public client (no secret returned)
curl -X POST $BASE/t/<workspace>/oauth/register -H 'Content-Type: application/json' -d '{
"client_name": "my-spa",
"redirect_uris": ["http://localhost:9898/callback"],
"token_endpoint_auth_method": "none"
}'
The returned client_id/client_secret plug straight into the normal /oauth/authorize → /oauth/token flow. /oauth/token looks up any dcr_* id in the DCR store; 0oamcp_* ids continue to use the deterministic per-workspace creds. Both work side-by-side.
Mode B — IAT-gated registration (enterprise scenario)
When you want to exercise the "server requires an Initial Access Token" path — that's what most enterprise IdPs do. Turn on the dcrRequireIat conformance toggle, mint an IAT, then register with it in the Authorization header.
# 1. Enable IAT enforcement on this workspace
curl -X PATCH $BASE/t/<workspace>/admin/conformance -H 'Content-Type: application/json' -d '{"toggles":{"dcrRequireIat":true}}'
# 2. Confirm bare registration is now rejected (401 invalid_token)
curl -sw '
%{http_code}
' -X POST $BASE/t/<workspace>/oauth/register -H 'Content-Type: application/json' -d '{"redirect_uris":["http://localhost/cb"]}'
# → 401 with WWW-Authenticate: Bearer realm="dcr", error="invalid_token", ...
# 3. Mint an IAT (default 1h; optional ttl_seconds in body 30..2592000)
IAT=$(curl -sX POST $BASE/t/<workspace>/admin/dcr/iat -H 'Content-Type: application/json' -d '{"ttl_seconds":3600}' | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")
# 4. Register with the IAT
curl -X POST $BASE/t/<workspace>/oauth/register -H 'Content-Type: application/json' -H "Authorization: Bearer $IAT" -d '{
"client_name": "my-agent",
"redirect_uris": ["http://localhost:9898/callback"],
"token_endpoint_auth_method": "client_secret_post"
}'
# → 201, returns credentials as in Mode A
IAT properties worth knowing:
- Tenant-scoped. An IAT minted at
/t/alice/admin/dcr/iatfails at/t/bob/oauth/registerwith401 invalid_token(wrong_tenant). - Time-limited. Default 1 h; configure per-request with
ttl_seconds(30..2592000). Expired tokens return401 invalid_token(expired). - Signature-verified. HS256 with server secret from
MCP_IAT_SIGNING_SECRET. Tampered tokens return401 invalid_token(bad_signature). - WWW-Authenticate carries the reason so client error-handling can distinguish expiry vs. tampering vs. tenant mismatch.
Common tuning knobs
| Setting | Effect |
|---|---|
dcrRequireJsonContentType toggle | Return 415 unless request is Content-Type: application/json. Test how your client handles the strict-media-type path. |
dcrEnforceRedirectUris toggle | Log a conformance violation when redirect_uris is malformed. Endpoint returns 400 either way; toggle just surfaces the violation in /admin/conformance/violations. |
GET /admin/dcr/clients | List all clients registered under this workspace (secrets omitted). Handy for cleanup between test scenarios. |
DELETE /admin/dcr/clients/:client_id | Force-delete one registration. |
POST /admin/reset | Wipes all DCR registrations for this workspace along with tools/prompts/resources. |
Admin REST API
All admin endpoints below live under /t/<name>/admin/. If MCP_ADMIN_TOKEN is set, requests must include Authorization: Bearer <token> (except /health and /config).
Workspace
| Endpoint | Description |
|---|---|
GET /admin/health | Liveness probe (unauthenticated) |
GET /admin/config | Per-workspace URLs + OAuth client_id/secret. Used by the dashboard to populate the registration card. |
GET /admin/state | Full state dump for this workspace (includes fault) |
POST /admin/reset | Wipe all tools/prompts/resources/oauth state and faults. Same as Reset state in the dashboard. |
POST /admin/seed?count=N&mode=append|replace | Add N synthetic tools (default count=10, mode=append) |
PATCH /admin/capabilities | Toggle which capability blocks initialize advertises. Body: { tools?: bool, prompts?: bool, resources?: bool } |
PATCH /admin/pagination | Change page size for list methods. Body: { pageSize: number } |
Tools, prompts, resources
| Endpoint | Description |
|---|---|
GET /admin/tools | List all tools |
POST /admin/tools | Add or replace by name. Body: { name, description?, inputSchema?, responseText?, responseDelayMs?, responseIsError? } — the last three configure the tools/call stub for this tool. See tool call stubs. |
PATCH /admin/tools/:name | Partial update (cannot rename). Pass null on a stub field to clear it. |
DELETE /admin/tools/:name | Remove a tool |
GET /admin/prompts | List prompts |
POST /admin/prompts | Add or replace by name. Body: { name, description? } |
DELETE /admin/prompts/:name | Remove a prompt |
GET /admin/resources | List resources |
POST /admin/resources | Add or replace by URI. Body: { uri, name?, mimeType?, description? } |
DELETE /admin/resources/:uri | Remove (URI must be URL-encoded) |
OAuth admin
| Endpoint | Description |
|---|---|
POST /admin/oauth/revoke | Revoke a refresh token. Body: { refresh_token } |
PATCH /admin/oauth/consent-enabled | Toggle the consent screen. Body: { enabled: boolean } |
GET /admin/dcr/clients | List DCR clients registered under this workspace (secrets omitted). |
DELETE /admin/dcr/clients/:client_id | Force-delete one DCR registration. |
Server-wide
| Endpoint | Description |
|---|---|
GET /admin/tenants | List all workspaces (server-wide) |
DELETE /admin/tenants/:tenantId | Delete a workspace and tombstone its name for 60s |
Tool call stubs
Each tool optionally configures how this server responds when its tools/call is invoked. With no stub config, a tool call returns an echo: the tool name and the arguments the caller passed. That's enough for verifying a gateway forwards arguments correctly. For more specific scenarios — exact response payloads, simulated tool errors, or per-tool slow responses — set the stub fields below.
| Field | Behavior |
|---|---|
responseText | String returned in content[0].text. If unset, the server echoes the tool name + arguments. |
responseDelayMs | Integer 0..60000. Per-tool delay applied before the response is returned. Stacks on top of fault.delayMs: if both are set, the tool call waits the sum. |
responseIsError | Boolean. When true, the response includes isError: true (the MCP "tool returned an error" flag). The JSON-RPC envelope is still 200 OK. |
responseContentBlocks | Array of MCP content blocks returned as content[], overriding responseText when set. See the "Content blocks" section below for the full concept + examples. |
Content blocks — what they are and how to use them
In MCP, every tools/call response includes a content[] array. Each element is a content block: one tagged, type-discriminated piece of tool output. The spec defines five types:
| type | Shape | Purpose |
|---|---|---|
text | {type, text} | Plain string output. |
image | {type, data (base64), mimeType} | Inline image bytes. |
audio | {type, data (base64), mimeType} | Inline audio bytes. |
resource_link | {type, uri, name?, mimeType?, size?, ...} | Pointer to a resource — bytes don't travel in the response. |
resource (embedded) | {type, resource: {uri, mimeType?, text|blob}} | Resource bytes inlined (text or base64 blob). |
A single tool response can mix types. This is by design in the spec and is the common pattern for real MCPs (GitHub's get_file_contents returns text + embedded resource; Figma MCPs return text + image; data-analysis MCPs return text + chart image + CSV link). Any combination of block types can coexist in one content[] array.
Historical context. The gateway at OKTA-1281521 used to drop every non-text block type (shipped-fixed in 2026-10). This fixture's content-block capability was built to reproduce that bug class; now it serves as a regression gate and reference for new content-block work.
Pre-registered demo workspaces
Two demo workspaces auto-seed on server boot:
- /t/content-blocks-showcase/ — comprehensive reference. 14 tools: one per pre-baked sample id (every size for every non-text type), plus
text_block, plusmixed_response(all 5 types in one call) andall_image_sizes(tiny → 1 MB images in one call). No conformance toggles, so you can hit tools without a session dance. Start here if you're seeing content blocks for the first time. - /t/okta-1281521-content-blocks-repro/ — focused regression gate for OKTA-1281521. Four tools, one per block type the gateway historically dropped.
sessionIdconformance toggle on (initialize once, echo the mintedMcp-Session-Idon subsequent requests;notifications/initializedis not required —initializedOrderingstays off).
Writing responseContentBlocks yourself
Validation rules (defaults; see Terraform tfvars to override):
- At most
MCP_MAX_CONTENT_BLOCKS_PER_TOOLentries (default 10) - Each entry is an object with a string
typefield type: "text"ortype: "resource_link"— admin-settable, accepted up toMCP_MAX_CONTENT_BLOCK_BYTES(default 4 KB)type: "sample"withid— references a server-side pre-baked fixture (see sample library below). Materialized to the real content block attools/calltime. Unknown ids become a text error block.type: "image","audio", embedded"resource"— prefer sample refs; inline forms accepted but soft-deprecated with aconsole.warn. (A later PR flips to hard-reject.)
Sample library
The server ships with 11 pre-baked fixtures covering the non-text content-block types (image, audio, resource_link, embedded resource) at a range of sizes. Text blocks are admin-settable inline, so there is no text sample. Call GET /t/<name>/admin/samples for the catalog:
curl https://mcp-test-server.sigmanetcorp.us/t/foo/admin/samples
Admin-controlled multimedia goes through the library instead of accepting arbitrary inline uploads (this server has no auth gate; the sample library is what keeps the fixture from becoming an S3-bloat vector). See knowledge/16-adr-content-block-samples.md for the design.
Examples
# Define a tool whose every call returns a fixed JSON body, slowly
curl -X POST localhost:3000/t/foo/admin/tools \
-H 'Content-Type: application/json' \
-d '{
"name": "search_issues",
"description": "search issues",
"inputSchema": {"type": "object"},
"responseText": "{\"issues\": [{\"id\": 1, \"title\": \"hello\"}]}",
"responseDelayMs": 2000
}'
# Make an existing tool simulate a tool-side error
curl -X PATCH localhost:3000/t/foo/admin/tools/search_issues \
-H 'Content-Type: application/json' \
-d '{"responseIsError": true, "responseText": "downstream search service unavailable"}'
# Clear all stub config on a tool (revert to echo behavior)
curl -X PATCH localhost:3000/t/foo/admin/tools/search_issues \
-H 'Content-Type: application/json' \
-d '{"responseText": null, "responseDelayMs": null, "responseIsError": null}'
# Calling an unknown tool name returns isError: true
curl -X POST localhost:3000/t/foo/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"does-not-exist","arguments":{}}}'
# → { "result": { "content": [{"type":"text","text":"...not found..."}], "isError": true }, ... }
# resource_link block, inline — uri + optional name/mimeType/size.
# Caps at MCP_MAX_CONTENT_BLOCK_BYTES per entry (default 4 KB).
curl -X POST localhost:3000/t/foo/admin/tools \
-H 'Content-Type: application/json' \
-d '{
"name": "get_big_file",
"description": "returns a resource_link",
"inputSchema": {"type": "object"},
"responseContentBlocks": [
{ "type": "resource_link", "uri": "https://example.com/big.bin",
"name": "big.bin", "mimeType": "application/octet-stream", "size": 2000000 }
]
}'
# image / audio / embedded resource — reference a sample fixture by id.
# Server materializes the real content block at tools/call time.
# Browse the catalog: curl localhost:3000/t/foo/admin/samples
curl -X POST localhost:3000/t/foo/admin/tools \
-H 'Content-Type: application/json' \
-d '{
"name": "get_image",
"description": "returns a PNG from the sample library",
"inputSchema": {"type": "object"},
"responseContentBlocks": [
{ "type": "sample", "id": "image_medium_png" }
]
}'
# Mixed-type response — the common real-world pattern. One tool call
# returns text + image + resource_link + embedded resource in one go.
# The MCP spec permits ANY combination of block types in one content[].
curl -X POST localhost:3000/t/foo/admin/tools \
-H 'Content-Type: application/json' \
-d '{
"name": "diagnostic_report",
"description": "returns summary text + chart image + link to logs",
"inputSchema": {"type": "object"},
"responseContentBlocks": [
{ "type": "text", "text": "Build failed. See chart and logs." },
{ "type": "sample", "id": "image_medium_png" },
{ "type": "resource_link", "uri": "https://ci.example.com/logs/42",
"name": "build-42.log", "mimeType": "text/plain", "size": 128000 }
]
}'
# Easiest way to see this in action: the pre-registered showcase workspace
# has a mixed_response tool that returns all 5 content-block types in
# one call, and an all_image_sizes tool that returns 4 image sizes in
# one call. Browse: GET /t/content-blocks-showcase/
Stub vs. fault delay — when to pick which
- Per-tool (
responseDelayMs) — only that tool's calls slow down. Useful when you wanttools/listsnappy buttools/callon tool X to time out. - Workspace-wide (
fault.delayMs) — every/mcprequest slows down (initialize, list, call). Useful for upstream-timeout-during-registration scenarios.
Fault injection
Configure a workspace to deliberately return error statuses or delays on its /mcp and /oauth/token endpoints. Useful for upstream-failure and timeout tests where a real MCP would never cooperate (UDRM-17, UDRM-19, UDRM-API-7/8/9).
Faults are deterministic per tenant — every request to a faulted endpoint gets the same response until you change or clear the fault. No probabilistic / multi-step recipes.
Configure via the dashboard
Each workspace dashboard has a Fault injection card with radio buttons for common statuses (401, 403, 404, 429, 500, 502, 503), a Custom… option, and a delay input. Save applies, Clear faults resets to pass-through.
Configure via the API
| Endpoint | Description |
|---|---|
GET /admin/fault | Current FaultState |
PATCH /admin/fault | Partial patch. Numeric values set the fault; null clears. |
Body shape:
{
"mcp": 100..599 | null, // status returned on POST /mcp
"oauthToken": 100..599 | null, // status returned on POST /oauth/token
"delayMs": 0..60000 | null, // delay applied to BOTH endpoints
"jsonrpcMethodErrors": { <method>: { "code": int, "message": string } | null } | null,
"jsonrpcMethodAsyncSse": [<method>, ...] | null // defer these methods over GET /mcp SSE
}
When a fault status is set for a scope, the response body is { "error": "fault_injected", "code": <status> } and the real handler is skipped. delayMs applies to both scopes (so registration-time timeouts can be simulated at either step).
Recipes
# 1. Force the MCP endpoint to return 500 on every request
curl -X PATCH localhost:3000/t/foo/admin/fault \
-H 'Content-Type: application/json' \
-d '{"mcp": 500}'
# 2. Make the workspace look "unauthorized" (401 on /mcp)
curl -X PATCH localhost:3000/t/foo/admin/fault \
-H 'Content-Type: application/json' \
-d '{"mcp": 401}'
# 3. Slow responses to simulate upstream timeout (35s on both endpoints)
curl -X PATCH localhost:3000/t/foo/admin/fault \
-H 'Content-Type: application/json' \
-d '{"delayMs": 35000}'
# 4. Independently fault the token endpoint
curl -X PATCH localhost:3000/t/foo/admin/fault \
-H 'Content-Type: application/json' \
-d '{"oauthToken": 503}'
# 5. Clear all faults
curl -X PATCH localhost:3000/t/foo/admin/fault \
-H 'Content-Type: application/json' \
-d '{"mcp": null, "oauthToken": null, "delayMs": null, "jsonrpcMethodErrors": null}'
JSON-RPC method faults
The jsonrpcMethodErrors field overrides the response for a specific JSON-RPC method with a chosen error, while the capability advertised in initialize stays intact. Common uses:
- Advertise-but-fail — capability shows up in
initializebut the corresponding list method returns-32601(mimics servers that ship a capability declaration ahead of the implementation). - Server-side error paths — simulate
-32603 Internal erroror-32700 Parse erroron a specific method to exercise the client's error surfacing. - Custom application codes — inject vendor-defined codes in the JSON-RPC server-error range (
-32000..-32099) to test how a client handles unfamiliar codes.
initialize and notifications/initialized cannot be overridden — the handshake stays intact so the tenant remains usable through the admin API and dashboard. Requests to a faulted method are short-circuited before dispatch reaches the normal handler.
Patch semantics: the jsonrpcMethodErrors object merges into the current map. Set a specific method key to null to remove just that entry; set the whole field to null to clear the map.
# 1. Advertise-but-fail: capabilities show resources+prompts, list methods 500
curl -X PATCH localhost:3000/t/foo/admin/fault \
-H 'Content-Type: application/json' \
-d '{"jsonrpcMethodErrors": {
"resources/list": {"code": -32601, "message": "Method not found"},
"prompts/list": {"code": -32601, "message": "Method not found"}
}}'
# 2. Simulate an internal server error on tools/call
curl -X PATCH localhost:3000/t/foo/admin/fault \
-H 'Content-Type: application/json' \
-d '{"jsonrpcMethodErrors": {"tools/call": {"code": -32603, "message": "Internal error"}}}'
# 3. Inject a custom application-level error
curl -X PATCH localhost:3000/t/foo/admin/fault \
-H 'Content-Type: application/json' \
-d '{"jsonrpcMethodErrors": {"tools/list": {"code": -32001, "message": "Backend unavailable"}}}'
# 4. Remove one entry (per-key null)
curl -X PATCH localhost:3000/t/foo/admin/fault \
-H 'Content-Type: application/json' \
-d '{"jsonrpcMethodErrors": {"resources/list": null}}'
# 5. Clear the whole method-fault map
curl -X PATCH localhost:3000/t/foo/admin/fault \
-H 'Content-Type: application/json' \
-d '{"jsonrpcMethodErrors": null}'
Precedence: if mcp (HTTP status) is also set, the HTTP fault fires first and JSON-RPC dispatch never runs — method faults are only observed on requests that would otherwise reach the handler.
Async-SSE delivery (Salesforce 2025-06-18 Streamable HTTP pattern)
The jsonrpcMethodAsyncSse field lists JSON-RPC method names that the mock should defer to the client's long-lived GET /mcp Server-Sent Events stream. When configured:
POST /mcpfor a listed method returns HTTP 202 Accepted with a literally empty body (noContent-Type, no bytes).- The actual JSON-RPC response frame is queued keyed by
Mcp-Session-Idand delivered as adata:SSE event on the client'sGET /mcpconnection to the same tenant. - If no SSE stream is open when the POST arrives, the frame is buffered until the next
GET /mcpfor that session connects. initializeandnotifications/initializedare protected — they always respond synchronously, so the handshake works.
Requires the sessionId conformance toggle to be on — the mock uses Mcp-Session-Id to route deferred frames to the right SSE stream. If a POST arrives with no session header, the request falls through to the synchronous path.
Multi-replica delivery. Pending frames are persisted to S3 under sse-pending/<tenantId>/<sessionId>/, so a POST handled by one replica is delivered on the GET SSE stream regardless of which replica owns it. The SSE handler drains the prefix on open and polls every ~2 s for frames written by peer replicas. See ADR 15.
Added to reproduce the Salesforce MCP server behavior: their spec-compliant server returns 202 empty on tools/list and streams the response over SSE; client implementations that treat 2xx as "response is in this body" fail with Empty response body errors. Use this fault to exercise the client's fix.
# 1. Reproduce Salesforce's tools/list-over-SSE pattern
curl -X PATCH localhost:3000/t/repro/admin/conformance \
-H 'Content-Type: application/json' \
-d '{"profile":"strict"}'
curl -X PATCH localhost:3000/t/repro/admin/fault \
-H 'Content-Type: application/json' \
-d '{"jsonrpcMethodAsyncSse": ["tools/list"]}'
# 2. Client flow (initialize returns 200 sync; tools/list returns 202 empty)
curl -i -X POST localhost:3000/t/repro/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'
# → 200, Mcp-Session-Id: <uuid> in response headers
curl -i -X POST localhost:3000/t/repro/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: <uuid>' -H 'MCP-Protocol-Version: 2025-06-18' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# → 202, empty body
# The real response arrives on the SSE stream:
curl -N localhost:3000/t/repro/mcp \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: <uuid>'
# → data: {"jsonrpc":"2.0","id":2,"result":{"tools":[...]}}
# 3. Clear it
curl -X PATCH localhost:3000/t/repro/admin/fault \
-H 'Content-Type: application/json' \
-d '{"jsonrpcMethodAsyncSse": null}'
POST /admin/reset clears them along with the rest of the state, and deleting a workspace removes everything.
Conformance
Configure a workspace to strictly enforce MCP streamable-HTTP spec rules (revisions 2025-03-26 / 2025-06-18) and OAuth 2.1 client-emission rules. Where fault injection tests how a client copes with a broken server, conformance tests whether a client emits requests that a spec-compliant server would accept — the mock plays the role of a strict vendor (e.g. Atlassian's Remote MCP) so integrations shake out client bugs before they reach real vendors.
Seventeen enforcement dimensions are toggleable per workspace; the strict profile flips all of them on at once, and per-dimension checkboxes overlay to tune. Every rejected request is captured in a per-workspace ring buffer (last 100) with the rule that was violated — visible in the dashboard's Recent violations table.
Streamable-HTTP transport dimensions (10)
| Dimension | Rule enforced |
|---|---|
sessionId | initialize mints an Mcp-Session-Id; post-init requests must echo it. Missing → 400, unknown → 404. DELETE /mcp terminates a session. |
protocolVersion | Post-init requests must send MCP-Protocol-Version matching the negotiated version. Missing/mismatch → 400. |
acceptHeader | Accept must list both application/json and text/event-stream. Wildcards do not satisfy the spec. |
contentType | POST /mcp must be Content-Type: application/json. Otherwise 415. |
jsonrpc | Body must have jsonrpc: "2.0" and a valid id. notifications/* methods must omit id entirely — "id": null is rejected with 400 jsonrpc_invalid. See below. |
initializedOrdering | tools/list, tools/call, prompts/list, resources/list are gated until notifications/initialized is received. |
origin | Origin must match the workspace's allowlist. Empty allowlist means allow-any-Origin when the toggle is on. |
methodRestrictions | /mcp accepts only POST, GET (SSE), DELETE. Other verbs return 405. |
oauthResourceParam | POST /oauth/token must include the RFC 8707 resource parameter (audience binding). |
wwwAuthenticate | 401 responses from /mcp carry WWW-Authenticate: Bearer resource_metadata="..." so clients can discover the protected-resource metadata URL. |
Notification shape: id must be omitted
JSON-RPC 2.0 §4.1 says a notification (a message that expects no reply, such as notifications/initialized) must omit the id member entirely. "id": null is different: the member is present, just empty. Lenient servers accept both; strict servers reject the null form (Salesforce's MCP server is one: it answers a notification carrying "id": null with 400, then 404 "Session not found" on later calls). With the jsonrpc dimension on, this workspace behaves like a strict one.
| Request body | Result with jsonrpc on |
|---|---|
{"jsonrpc":"2.0","method":"notifications/initialized"} | 202 Accepted, session marked initialized. |
{"jsonrpc":"2.0","id":null,"method":"notifications/initialized"} | 400 jsonrpc_invalid, violation logged, session not marked initialized. |
{"jsonrpc":"2.0","id":5,"method":"notifications/initialized"} | 400 jsonrpc_invalid. Any id on a notification is rejected, not just null. |
{"jsonrpc":"2.0","id":null,"method":"tools/list"} | 400 jsonrpc_invalid. Requests still need a string or number id. |
Scenarios you can reproduce
| To reproduce… | Set up | Expected |
|---|---|---|
| A client whose handshake looks successful but every later call fails (the Salesforce pattern) | Profile strict (turns on sessionId, jsonrpc, initializedOrdering). Client sends initialize, then a notification carrying "id": null, then tools/list. |
initialize 200; notification 400 jsonrpc_invalid; tools/list 400 not_initialized. A client that only logs the notification failure will report "connected" and then break on its first real call. |
A client that serializes an unset id as null |
Only jsonrpc on. Send any notification with "id": null. |
400 jsonrpc_invalid, logged under the jsonrpc dimension with id=null in the rule text. Typical cause: one request type shared by requests and notifications, with no "omit when null" setting on the serializer. |
| Whether a client surfaces a failed notification instead of swallowing it | Same as the first scenario. Watch the client's own logs and the result of its connect/discover call. | A correct client fails the connect (or at least reports the 400). A client that treats notification failures as non-fatal succeeds on connect and fails later. |
| That a fix works (regression check) | Same strict workspace, run the client again after the fix. | Notification 202, tools/list 200, and no jsonrpc entries in /admin/conformance/violations. |
| That a lenient server still works with your client | Profile off, or jsonrpc toggled off while the others stay on. |
"id": null notifications are accepted as before (202), so you can compare lenient and strict behavior on the same workspace. |
Try it by hand
# 1. strict workspace, then initialize and keep the session id
curl -X PATCH $BASE/t/$WS/admin/conformance -H 'Content-Type: application/json' -d '{"profile":"strict"}'
SID=$(curl -s -D - -o /dev/null -X POST $BASE/t/$WS/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | tr -d '\r' | sed -n 's/^[Mm]cp-[Ss]ession-[Ii]d: //p')
# 2. notification with "id": null -> 400 jsonrpc_invalid
curl -X POST $BASE/t/$WS/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H "Mcp-Session-Id: $SID" -H 'MCP-Protocol-Version: 2025-06-18' \
-d '{"jsonrpc":"2.0","id":null,"method":"notifications/initialized","params":{}}'
# 3. tools/list now -> 400 not_initialized (the handshake never completed)
curl -X POST $BASE/t/$WS/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H "Mcp-Session-Id: $SID" -H 'MCP-Protocol-Version: 2025-06-18' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# 4. same notification without the id member -> 202, then tools/list -> 200
Every rejection is recorded in GET /t/:t/admin/conformance/violations. To compare against a lenient server, PATCH /t/:t/admin/conformance with {"toggles":{"jsonrpc":false}} and repeat step 2: it returns 202.
OAuth client-emission dimensions (4)
| Dimension | Rule enforced |
|---|---|
pkce | RFC 7636. /authorize requires code_challenge (S256 or plain). /token requires code_verifier whose SHA256 hash matches the stored challenge. Even with the toggle off, the mock honors PKCE if the client uses it. |
oauthResourceOnAuthorize | RFC 8707 §2. /authorize must include a resource parameter (complements oauthResourceParam on the token endpoint). |
oauthTokenContentType | POST /oauth/token must be application/x-www-form-urlencoded. JSON bodies get 415. |
oauthState | /authorize must include a state parameter (CSRF protection). |
DCR dimensions (3)
| Dimension | Rule enforced |
|---|---|
dcrRequireIat | RFC 7591. /oauth/register requires Authorization: Bearer <IAT>. The IAT is a HS256-signed JWT minted by POST /admin/dcr/iat, scoped to the tenant it was issued for, with configurable TTL. Invalid tokens get 401 invalid_token with the specific reason in WWW-Authenticate. |
dcrRequireJsonContentType | POST /oauth/register requires Content-Type: application/json. Other types get 415. |
dcrEnforceRedirectUris | Log a conformance violation when redirect_uris is missing/malformed on /oauth/register. (The endpoint returns 400 regardless; this dimension surfaces the violation in /admin/conformance/violations.) |
Configure via the dashboard
Each workspace dashboard has a Conformance card. Pick Profile: strict to enable all seventeen toggles at once, or check individual dimensions. Set the advertised protocolVersion (default 2025-06-18) to test downgrade negotiation. The Active sessions and Recent violations tables refresh every 2 s so you can watch a client integration exercise the workspace live.
Configure via the API
| Endpoint | Description |
|---|---|
GET /admin/conformance | Current profile, toggles, protocolVersion, origin allowlist, session and violation counts |
PATCH /admin/conformance | Merge patch. Fields: profile, toggles, protocolVersion, originAllowlist |
POST /admin/conformance/reset | Reset conformance state (profile off, empty allowlist, cleared sessions and violations) |
GET /admin/conformance/violations | Last 100 spec violations with rule, dimension, method, and request summary |
DELETE /admin/conformance/violations | Clear the ring buffer |
GET /admin/conformance/sessions | Active MCP sessions (id, created, last activity, initialized flag, negotiated version) |
DELETE /admin/conformance/sessions/<id> | Force-terminate one session |
Recipes
# 1. Turn a workspace into a strict spec-compliant server
curl -X PATCH localhost:3000/t/strict-repro/admin/conformance \
-H 'Content-Type: application/json' \
-d '{"profile": "strict"}'
# 2. Only enforce session id + protocol version (reproduce the Atlassian bug)
curl -X PATCH localhost:3000/t/atlassian-repro/admin/conformance \
-H 'Content-Type: application/json' \
-d '{"toggles": {"sessionId": true, "protocolVersion": true}}'
# 3. Strict, but skip Origin (client doesn't send Origin header)
curl -X PATCH localhost:3000/t/strict-no-origin/admin/conformance \
-H 'Content-Type: application/json' \
-d '{"profile": "strict", "toggles": {"origin": false}}'
# 4. Restrict which Origins can hit /mcp
curl -X PATCH localhost:3000/t/strict-repro/admin/conformance \
-H 'Content-Type: application/json' \
-d '{"toggles": {"origin": true}, "originAllowlist": ["https://okta.com"]}'
# 5. Test the client against an older protocol version
curl -X PATCH localhost:3000/t/downgrade-test/admin/conformance \
-H 'Content-Type: application/json' \
-d '{"protocolVersion": "2024-11-05"}'
# 6. Inspect what the client did wrong
curl localhost:3000/t/strict-repro/admin/conformance/violations
# 7. Reset back to lax defaults
curl -X POST localhost:3000/t/strict-repro/admin/conformance/reset
POST /admin/reset (which wipes everything) or by POST /admin/conformance/reset (which wipes only conformance state — profile, toggles, sessions, and violations). Session idle timeout defaults to 1 hour and is overridable via the SESSION_IDLE_MS environment variable at server start — note that SESSION_IDLE_MS only applies when the sessionId toggle is off; when it's on, sessions are S3-backed and expire via the bucket lifecycle rule, which fires ~48–72h after the last putSession (daily S3 scheduler + days = 2 eligibility, reset on each overwrite). Not idle-based. See ADR 13.
CIMD publisher
Server-wide publisher for OAuth Client ID Metadata Documents. Lets you host CIMD JSON docs at stable public URLs for testing Okta CIMD registration flows — Okta AS fetches the URL unauthenticated at token time and reads redirect_uris, jwks_uri, etc.
Open the CIMD dashboard for the browser UI, or use the API. The client_id field in the stored document is auto-set to the serving URL on publish (the CIMD draft requires them to match).
| Endpoint | Purpose |
|---|---|
GET /cimd | Dashboard: publish form + list of stored docs |
GET /cimd/<name>.json | Public metadata document (what Okta AS reads) |
GET /cimd/list.json | Array of stored docs |
POST /cimd/api/docs | Create/update. Body: {"name":"…","doc":{…}} |
DELETE /cimd/api/docs/<name> | Remove a stored doc |
# Publish
curl -X POST $BASE/cimd/api/docs \
-H 'Content-Type: application/json' \
-d '{"name":"claude-ashim","doc":{"redirect_uris":["http://localhost:9898/callback"],"response_types":["code"],"token_endpoint_auth_method":"none"}}'
# Fetch (what Okta AS does)
curl $BASE/cimd/claude-ashim.json
# Delete
curl -X DELETE $BASE/cimd/api/docs/claude-ashim
Documents are stored in the S3 state bucket (cimd/<name>.json). Publishes and fetches work correctly with any replica count and survive task restarts.
Admin auth
Admin endpoints are unauthenticated unless MCP_ADMIN_TOKEN is set in the server's environment. When set, every admin request (other than /admin/health and /admin/config) must include the bearer token.
curl -H 'Authorization: Bearer $MCP_ADMIN_TOKEN' \
localhost:3000/t/foo/admin/state
The dashboard surfaces a token field automatically when the server returns 401; paste your token and the page will start including it on subsequent fetches.
Further reading
- README — full architecture and quickstart
- MCP specification — the protocol this server implements