Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

MCP server

Rustybin is a mock Model Context Protocol server for MCP gateway demos: routing on MCP headers, authorization (OAuth 2.1 and API keys), tool filtering, aggregation of several servers, guardrails on tool output, and observability of agent traffic. Everything it returns is fake, deterministic test data. It is exercised by the official Python MCP SDK (and optionally the MCP Inspector CLI) in conformance/mcp/.

Endpoints and variants

EndpointVariant
POST, GET, DELETE /mcpOpen server, Streamable HTTP transport
POST, GET, DELETE /mcp/protectedSame server behind OAuth 2.1 bearer tokens (MCP authorization spec)
POST, GET, DELETE /mcp/apikeySame server behind an X-API-Key header
POST, GET, DELETE /mcp/servers/{name}Named servers with tool subsets: weather, crm, devtools
GET /mcp/sse + POST /mcp/messages?sessionId=Legacy HTTP+SSE transport (2024-11-05)
GET /.well-known/oauth-protected-resource and .../oauth-protected-resource/mcp/protectedRFC 9728 metadata of /mcp/protected

Every Streamable HTTP response carries X-Rustybin-Mcp-Server, X-Rustybin-Mcp-Session (stateless for 2026-07-28 requests), X-Rustybin-Mcp-Method, X-Rustybin-Mcp-Request-Id and X-Rustybin-Mcp-Protocol-Version, handy when checking what a gateway routed where.

Protocol versions

VersionStyle
2026-07-28Stateless: no initialize; every request carries params._meta["io.modelcontextprotocol/protocolVersion"] and clientCapabilities, plus the MCP-Protocol-Version, Mcp-Method and (for tools/call, prompts/get, resources/read) Mcp-Name headers; server/discover; list results carry ttlMs and cacheScope; elicitation and sampling are multi round-trip requests; subscriptions/listen
2025-11-25, 2025-06-18, 2025-03-26Handshake: initialize returns Mcp-Session-Id (required afterwards), GET opens the server-to-client stream, DELETE ends the session; MCP-Protocol-Version header from 2025-06-18
2024-11-05HTTP+SSE transport at /mcp/sse, or initialize with this version

The era is chosen per message: a request with the _meta protocol version, or an MCP-Protocol-Version header naming 2026-07-28, is served statelessly; everything else follows the handshake.

2026-07-28 (stateless)

The routing headers must match the body (gateways route on them without parsing JSON): MCP-Protocol-Version equals the _meta version, Mcp-Method the method, Mcp-Name the tool, prompt or resource, and for tool arguments that a tool marks as header-routed (such as get_weather's city), an Mcp-Param-<Name> header. Mismatches are JSON-RPC error -32020 with HTTP 400.

POST {{base_url}}/mcp
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: server/discover
{
  "jsonrpc": "2.0", "id": 1, "method": "server/discover",
  "params": {"_meta": {
    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
    "io.modelcontextprotocol/clientCapabilities": {}
  }}
}
HTTP 200
[Asserts]
header "X-Rustybin-Mcp-Session" == "stateless"
jsonpath "$.result.supportedVersions" contains "2026-07-28"
jsonpath "$.result.supportedVersions" contains "2025-06-18"
jsonpath "$.result.capabilities.tools" exists
POST {{base_url}}/mcp
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/list
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}
HTTP 200
[Asserts]
jsonpath "$.result.tools[*].name" contains "get_weather"
jsonpath "$.result.tools[*].name" contains "inspect_request"
jsonpath "$.result.ttlMs" exists
jsonpath "$.result.cacheScope" exists
POST {{base_url}}/mcp
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather
Mcp-Param-City: Paris
{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "get_weather", "arguments": {"city": "Paris"}, "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}
HTTP 200
[Asserts]
header "X-Rustybin-Mcp-Method" == "tools/call"
jsonpath "$.result.isError" == false
jsonpath "$.result.structuredContent.city" == "Paris"
jsonpath "$.result.content[0].type" == "text"
# The routing headers must match the body (gateways route on them).
POST {{base_url}}/mcp
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: calculate
{"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "echo", "arguments": {"message": "hi"}, "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}
HTTP 400
[Asserts]
jsonpath "$.error.code" == -32020
jsonpath "$.error.message" contains "Mcp-Name"
POST {{base_url}}/mcp
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: resources/read
Mcp-Name: rustybin://docs/readme
{"jsonrpc": "2.0", "id": 5, "method": "resources/read", "params": {"uri": "rustybin://docs/readme", "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}
HTTP 200
[Asserts]
jsonpath "$.result.contents[0].mimeType" == "text/markdown"

POST {{base_url}}/mcp
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: prompts/get
Mcp-Name: summarize
{"jsonrpc": "2.0", "id": 6, "method": "prompts/get", "params": {"name": "summarize", "arguments": {"text": "hello", "style": "bullets"}, "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}
HTTP 200
[Asserts]
jsonpath "$.result.messages[0].role" == "user"

Responses are plain JSON unless the server has something to stream first (progress notifications, logs at the requested _meta log level): then the answer is an SSE stream that ends with the result. ?page_size=N paginates list results (default 100) with nextCursor.

# With a progressToken, slow_task answers as an SSE stream: progress
# notifications first, then the result.
POST {{base_url}}/mcp
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: slow_task
{"jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": {"name": "slow_task", "arguments": {"duration_ms": 300, "steps": 3}, "_meta": {"progressToken": "p1", "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}
HTTP 200
[Asserts]
header "Content-Type" contains "text/event-stream"
body contains "notifications/progress"
body contains "\"progressToken\":\"p1\""
body contains "\"id\":10"
POST {{base_url}}/mcp?page_size=3
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/list
{"jsonrpc": "2.0", "id": 11, "method": "tools/list", "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}
HTTP 200
[Asserts]
jsonpath "$.result.tools" count == 3
jsonpath "$.result.nextCursor" exists

Handshake versions (2025-xx)

POST {{base_url}}/mcp
Accept: application/json, text/event-stream
Content-Type: application/json
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "docs", "version": "1.0"}}}
HTTP 200
[Captures]
session: header "Mcp-Session-Id"
[Asserts]
jsonpath "$.result.protocolVersion" == "2025-06-18"
jsonpath "$.result.serverInfo.name" == "rustybin-mcp"

POST {{base_url}}/mcp
Mcp-Session-Id: {{session}}
MCP-Protocol-Version: 2025-06-18
Accept: application/json, text/event-stream
Content-Type: application/json
{"jsonrpc": "2.0", "method": "notifications/initialized"}
HTTP 202
POST {{base_url}}/mcp
Mcp-Session-Id: {{session}}
MCP-Protocol-Version: 2025-06-18
Accept: application/json, text/event-stream
Content-Type: application/json
{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "calculate", "arguments": {"expression": "(2 + 3) * 4"}}}
HTTP 200
[Asserts]
jsonpath "$.result.content[0].text" contains "20"
DELETE {{base_url}}/mcp
Mcp-Session-Id: {{session}}
HTTP 200

# The session is gone afterwards.
POST {{base_url}}/mcp
Mcp-Session-Id: {{session}}
MCP-Protocol-Version: 2025-06-18
Accept: application/json, text/event-stream
Content-Type: application/json
{"jsonrpc": "2.0", "id": 3, "method": "tools/list"}
HTTP 404

A GET with the session id opens the server-to-client SSE stream (resource update notifications after resources/subscribe, server requests such as elicitation/create and sampling/createMessage). Without a session, GET and DELETE answer 400; an unknown or deleted session answers 404. Sessions are bounded (1000, idle TTL 30 minutes; 200 and 10 minutes in public mode).

Legacy HTTP+SSE (2024-11-05)

GET /mcp/sse opens a stream whose first event is endpoint, carrying /mcp/messages?sessionId=.... The client POSTs JSON-RPC messages there and gets 202 Accepted; responses and notifications arrive as message events on the stream. The session ends with the stream. Because the two halves must run at the same time, this transport has no Hurl example; conformance/mcp/test_sse_legacy.py drives it with the Python SDK.

Tools, resources and prompts

ToolPurpose
echo, add, calculateDeterministic utilities (calculate evaluates + - * / % ^ and parentheses)
get_weather, get_timeFake weather (same city, same answer) and time in a timezone
lookup_customer, search_ordersCRM data, the same customers (u1 to u5) and orders as GraphQL
cancel_orderA "destructive" tool (nothing changes) that needs the mcp:tools:write scope on /mcp/protected
slow_taskRuns duration_ms in steps with progress notifications and logs; honours cancellation
fail, throwA tool error (isError: true) and a JSON-RPC error
large_outputAbout kb kilobytes of text (capped), for response size limits
generate_imageA small PNG as image content
fetch_resource_linkA resource_link content block (embedded resource before 2025-06-18)
prompt_injection_demoClearly labelled injection text in tool output, for guardrail demos
elicit_confirmation, sample_llmElicitation and sampling (multi round-trip on 2026-07-28, server requests before)
inspect_requestThe HTTP request the server received: headers (credentials masked), client IP, token claims, session, transport

Resources: rustybin://docs/readme (Markdown), rustybin://data/customers.json, rustybin://images/logo.png (blob) and rustybin://clock (subscribable, updates every RUSTYBIN_MCP_CLOCK_TICK_SECS), plus the templates rustybin://customers/{id} and rustybin://weather/{city}. Prompts: summarize (text, style), code_review (code, language, focus) and incident_report (service, severity, summary; embeds a runbook resource). completion/complete completes prompt arguments and template variables; logging/setLevel (or the _meta log level on 2026-07-28) controls log notifications.

inspect_request is the tool to show identity propagation: call it through the gateway and the reply lists the headers the gateway injected.

Named servers

/mcp/servers/{name} are separate MCP servers (their own serverInfo) for aggregation and per-server routing demos:

ServerTools
weatherget_weather, get_time, inspect_request
crmlookup_customer, search_orders, cancel_order, inspect_request
devtoolsecho, add, calculate, get_time, slow_task, fail, throw, large_output, generate_image, fetch_resource_link, prompt_injection_demo, elicit_confirmation, sample_llm, inspect_request

Unknown names answer 404 with the list of available servers.

POST {{base_url}}/mcp/servers/crm
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/list
{"jsonrpc": "2.0", "id": 7, "method": "tools/list", "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}
HTTP 200
[Asserts]
header "X-Rustybin-Mcp-Server" == "crm"
jsonpath "$.result.tools[*].name" contains "lookup_customer"
jsonpath "$.result.tools[*].name" not contains "get_weather"

API key variant

/mcp/apikey requires X-API-Key: any non-empty value, or exactly RUSTYBIN_MCP_API_KEY when that is set. Use it to show a gateway injecting the key so that agents never see it.

POST {{base_url}}/mcp/apikey
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: server/discover
{"jsonrpc": "2.0", "id": 8, "method": "server/discover", "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}
HTTP 401
[Asserts]
jsonpath "$.error_description" == "X-API-Key header required"

POST {{base_url}}/mcp/apikey
X-API-Key: injected-by-gateway
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: server/discover
{"jsonrpc": "2.0", "id": 9, "method": "server/discover", "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}
HTTP 200

OAuth-protected variant

/mcp/protected follows the MCP authorization spec with the built-in identity provider as authorization server:

  1. Without a valid token it answers 401 with WWW-Authenticate: Bearer resource_metadata="<base>/.well-known/oauth-protected-resource/mcp/protected", scope="mcp:tools".
  2. The RFC 9728 metadata names the resource (<base>/mcp/protected, or RUSTYBIN_MCP_RESOURCE_URL), the authorization server (this Rustybin) and the scopes mcp:tools and mcp:tools:write.
  3. The client registers itself (RFC 7591) and gets a token with resource=<base>/mcp/protected (RFC 8707), typically through authorization code
    • PKCE.
  4. Tokens must be RS256 tokens from the built-in provider whose audience is the resource URL, or one of RUSTYBIN_MCP_ACCEPTED_AUDIENCES (default rustybin, so a plain client credentials token works; set it to none for strict audience checks).
  5. cancel_order needs mcp:tools:write; without it the call gets 403 with an insufficient_scope challenge (step-up authorization).

The base URL comes from the request, like the built-in IdP's issuer: https on the HTTPS listener, the Host header, and Forwarded / X-Forwarded-Proto / X-Forwarded-Host / X-Forwarded-Port with RUSTYBIN_TRUST_FORWARD=true (RUSTYBIN_MCP_RESOURCE_URL still overrides the resource identifier).

POST {{base_url}}/mcp/protected
Accept: application/json, text/event-stream
Content-Type: application/json
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "docs", "version": "1.0"}}}
HTTP 401
[Asserts]
header "WWW-Authenticate" contains "resource_metadata=\"{{base_url}}/.well-known/oauth-protected-resource/mcp/protected\""
header "WWW-Authenticate" contains "scope=\"mcp:tools\""
GET {{base_url}}/.well-known/oauth-protected-resource/mcp/protected
HTTP 200
[Asserts]
jsonpath "$.resource" == "{{base_url}}/mcp/protected"
jsonpath "$.authorization_servers[0]" == "{{base_url}}"
jsonpath "$.scopes_supported" contains "mcp:tools:write"
# A token for this resource (RFC 8707) from the built-in identity provider.
POST {{base_url}}/oauth/token
[BasicAuth]
rustybin: secret
[Form]
grant_type: client_credentials
scope: mcp:tools
resource: {{base_url}}/mcp/protected
HTTP 200
[Captures]
mcp_token: jsonpath "$.access_token"

POST {{base_url}}/mcp/protected
Authorization: Bearer {{mcp_token}}
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: inspect_request
{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "inspect_request", "arguments": {}, "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}
HTTP 200
[Asserts]
jsonpath "$.result.isError" == false
jsonpath "$.result.structuredContent.tokenClaims.aud" == "{{base_url}}/mcp/protected"
jsonpath "$.result.structuredContent.tokenClaims.scope" == "mcp:tools"
jsonpath "$.result.structuredContent.headers.authorization" contains "masked"
# cancel_order needs mcp:tools:write: 403 with an insufficient_scope challenge.
POST {{base_url}}/mcp/protected
Authorization: Bearer {{mcp_token}}
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: cancel_order
{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "cancel_order", "arguments": {"order_id": "o1"}, "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}
HTTP 403
[Asserts]
header "WWW-Authenticate" contains "error=\"insufficient_scope\""
header "WWW-Authenticate" contains "scope=\"mcp:tools:write\""

Origin validation and limits

Every MCP endpoint checks the Origin header against RUSTYBIN_MCP_ALLOWED_ORIGINS (default *): a browser request from another origin gets 403 (DNS rebinding protection, as the transport spec requires). Long-lived streams (GET streams, HTTP+SSE, subscriptions/listen) are closed after an hour (5 minutes in public mode); request bodies are limited to 4 MiB (256 KiB).

Conformance

PYTHON=/path/to/venv/bin/python conformance/mcp/run.sh               # Python SDK scripts
PYTHON=/path/to/venv/bin/python conformance/mcp/run.sh --inspector   # plus the MCP Inspector CLI (Node, npm registry)