mcp-test-server · docs

← landing

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).

URLPurpose
/Landing page — pick or auto-generate a workspace
/t/<name>/Workspace dashboard (HTML)
/t/<name>/mcpMCP 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.

MethodBehavior
initializeReturns protocolVersion + advertised capabilities (tools/prompts/resources, gated by state.capabilities)
tools/listPaginated list (page size from state.pagination.pageSize)
tools/callLooks 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/listPaginated
resources/listPaginated

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.

EndpointPurpose
GET /oauth/authorizeRedirects with an auth code (or shows consent if enabled)
POST /oauth/tokenExchange 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/registerRFC 7591 — register a client at runtime; get back client_id (+ client_secret for confidential clients).
GET /oauth/.well-known/oauth-authorization-serverAuthorization 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

ModeBehavior
offDefault. No bearer validation on /mcp. Existing perf tests and legacy flows keep working.
strictExternal Okta tokens only. Internal mcp_at_* tokens are rejected 401 invalid_token.
hybridAccepts both. Token starts with mcp_at_ → local verification; otherwise → Okta path (JWT or introspection).

Admin API

EndpointPurpose
GET /admin/authRead current mode + allowedIssuers[]. client_secret is replaced with client_secret_set: true|false — the plaintext and ciphertext are never returned.
PATCH /admin/authSet mode and/or allowedIssuers[]. Full-replace on allowedIssuers[]. Config-write validation returns 400 with specific reasons on violation.
DELETE /admin/authClear 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

  1. Bearer extracted from Authorization: Bearer <t> or ?access_token=<t> query param (the query-param path exists so EventSource clients on GET /mcp can present a token).
  2. If hybrid and token starts with mcp_at_ → local deterministic verification; otherwise Okta path.
  3. Okta path: three-dot JWT → verify signature via cached JWKS for the matching issuer_url, check exp/nbf with clock-skew grace, check aud matches the entry's audience, check required_scopes are all present in scope/scp. Non-JWT → introspection via introspection_uri with the configured client_credentials.

Error responses (RFC 6750)

FailureStatuserrorerror_description
Missing bearer401invalid_requestMissing Bearer token in Authorization header
Expired401invalid_tokenToken has expired (exp claim)
Bad signature401invalid_tokenSignature verification failed against JWKS
Unknown iss401invalid_tokenIssuer not in allowed_issuers
Wrong aud401invalid_tokenAudience mismatch: expected '<audience>'
Missing required scope401insufficient_scopeToken missing required scope '<scope>'
JWKS fetch failed503service_unavailable(generic; check server logs for the issuer)

Fleet-wide tunables (env vars)

Env varDefaultPurpose
MCP_SECRET_ENCRYPTION_KEYdeterministic default + boot warningBase64-encoded 32-byte key for AES-256-GCM encryption of client_secret at rest.
MCP_JWKS_CACHE_TTL_MS600000 (10 min)JWKS cache TTL.
MCP_JWKS_KID_MISS_COOLDOWN_MS30000 (30 s)Per-issuer cooldown after a kid-miss refresh.
MCP_JWT_CLOCK_SKEW_MS30000 (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:

Common tuning knobs

SettingEffect
dcrRequireJsonContentType toggleReturn 415 unless request is Content-Type: application/json. Test how your client handles the strict-media-type path.
dcrEnforceRedirectUris toggleLog 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/clientsList all clients registered under this workspace (secrets omitted). Handy for cleanup between test scenarios.
DELETE /admin/dcr/clients/:client_idForce-delete one registration.
POST /admin/resetWipes 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

EndpointDescription
GET /admin/healthLiveness probe (unauthenticated)
GET /admin/configPer-workspace URLs + OAuth client_id/secret. Used by the dashboard to populate the registration card.
GET /admin/stateFull state dump for this workspace (includes fault)
POST /admin/resetWipe all tools/prompts/resources/oauth state and faults. Same as Reset state in the dashboard.
POST /admin/seed?count=N&mode=append|replaceAdd N synthetic tools (default count=10, mode=append)
PATCH /admin/capabilitiesToggle which capability blocks initialize advertises. Body: { tools?: bool, prompts?: bool, resources?: bool }
PATCH /admin/paginationChange page size for list methods. Body: { pageSize: number }

Tools, prompts, resources

EndpointDescription
GET /admin/toolsList all tools
POST /admin/toolsAdd 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/:namePartial update (cannot rename). Pass null on a stub field to clear it.
DELETE /admin/tools/:nameRemove a tool
GET /admin/promptsList prompts
POST /admin/promptsAdd or replace by name. Body: { name, description? }
DELETE /admin/prompts/:nameRemove a prompt
GET /admin/resourcesList resources
POST /admin/resourcesAdd or replace by URI. Body: { uri, name?, mimeType?, description? }
DELETE /admin/resources/:uriRemove (URI must be URL-encoded)

OAuth admin

EndpointDescription
POST /admin/oauth/revokeRevoke a refresh token. Body: { refresh_token }
PATCH /admin/oauth/consent-enabledToggle the consent screen. Body: { enabled: boolean }
GET /admin/dcr/clientsList DCR clients registered under this workspace (secrets omitted).
DELETE /admin/dcr/clients/:client_idForce-delete one DCR registration.

Server-wide

EndpointDescription
GET /admin/tenantsList all workspaces (server-wide)
DELETE /admin/tenants/:tenantIdDelete 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.

FieldBehavior
responseTextString returned in content[0].text. If unset, the server echoes the tool name + arguments.
responseDelayMsInteger 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.
responseIsErrorBoolean. When true, the response includes isError: true (the MCP "tool returned an error" flag). The JSON-RPC envelope is still 200 OK.
responseContentBlocksArray 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:

typeShapePurpose
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:

Writing responseContentBlocks yourself

Validation rules (defaults; see Terraform tfvars to override):

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

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

EndpointDescription
GET /admin/faultCurrent FaultState
PATCH /admin/faultPartial 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:

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:

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}'
Faults survive workspace activity but not reset/delete: 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)

DimensionRule enforced
sessionIdinitialize mints an Mcp-Session-Id; post-init requests must echo it. Missing → 400, unknown → 404. DELETE /mcp terminates a session.
protocolVersionPost-init requests must send MCP-Protocol-Version matching the negotiated version. Missing/mismatch → 400.
acceptHeaderAccept must list both application/json and text/event-stream. Wildcards do not satisfy the spec.
contentTypePOST /mcp must be Content-Type: application/json. Otherwise 415.
jsonrpcBody 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.
initializedOrderingtools/list, tools/call, prompts/list, resources/list are gated until notifications/initialized is received.
originOrigin 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.
oauthResourceParamPOST /oauth/token must include the RFC 8707 resource parameter (audience binding).
wwwAuthenticate401 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 bodyResult 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 upExpected
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)

DimensionRule enforced
pkceRFC 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.
oauthResourceOnAuthorizeRFC 8707 §2. /authorize must include a resource parameter (complements oauthResourceParam on the token endpoint).
oauthTokenContentTypePOST /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)

DimensionRule enforced
dcrRequireIatRFC 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.
dcrRequireJsonContentTypePOST /oauth/register requires Content-Type: application/json. Other types get 415.
dcrEnforceRedirectUrisLog 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

EndpointDescription
GET /admin/conformanceCurrent profile, toggles, protocolVersion, origin allowlist, session and violation counts
PATCH /admin/conformanceMerge patch. Fields: profile, toggles, protocolVersion, originAllowlist
POST /admin/conformance/resetReset conformance state (profile off, empty allowlist, cleared sessions and violations)
GET /admin/conformance/violationsLast 100 spec violations with rule, dimension, method, and request summary
DELETE /admin/conformance/violationsClear the ring buffer
GET /admin/conformance/sessionsActive 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
Conformance state is per-workspace and reset by 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).

EndpointPurpose
GET /cimdDashboard: publish form + list of stored docs
GET /cimd/<name>.jsonPublic metadata document (what Okta AS reads)
GET /cimd/list.jsonArray of stored docs
POST /cimd/api/docsCreate/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