Project: Offline MCP Message Validation
Project goal
Complete a repeatable MCP teaching-validation project without a network, real server, SDK, or API key. It turns the learning path’s key rules into data-driven checks:
- Strict JSON and the JSON-RPC envelope.
- The initialize state machine and bidirectional request IDs.
- Client/server capability direction and sub-capabilities.
- Tool inputs, structured outputs, and two kinds of error.
- The five stable Resources methods, pagination, content, and subscription state.
- HTTP token audience/resource, tenant, scope, and authorization-revision binding.
- Key boundaries for roots, sampling, elicitation, and experimental Tasks.
- Positive, negative, and regression tests.
Capability boundary
This is the course-defined
offline-mcp-teaching-profile-v2, not the official MCP conformance suite. It implements only the methods and schema subset used by the course from the stable2025-11-25specification, and deliberately tightens rules for unknown fields, URI, Base64, and a 64 KiB content budget. It does not implement the Draft candidate protocol. A real implementation should prefer an official SDK and test transport, interoperability, and security against the versions on both sides.
Separate wire and control plane
Fixture
transport_context_defaults, per-steptransport_context,authorization_revision, andcontrol_eventare course control-plane metadata, not MCP JSON-RPC fields. They model the trusted identity snapshot obtained after HTTP-layer signature validation, token introspection, and access policy; never copy them into a real MCP message.
Next layer of executable evidence
After this project, run the Loopback Streamable HTTP and OAuth Resource Boundaries project. It does not modify or reuse this validator’s
transport_contextfixtures. Instead, it independently validates HTTP headers/statuses, JSON/SSE, sessions, PRM document/challenge shape, and an offline token policy on a real endpoint. Its HTTP resource does not claim RFC 9728 conformance. The projects cover the message contract and transport/resource boundary separately.
Project files
| File | Purpose |
|---|---|
| validate_mcp_messages.py | State machine, capability gates, schema subset, and command-line entry point |
| mcp-cases.json | 54 data-driven positive/negative cases |
| test_validate_mcp_messages.py | 108 automated regression tests |
The project uses only the Python 3 standard library. Fixtures contain no network addresses other than the documentation-reserved example.com domain, no real user data, and no credentials.
Runtime environment
This course was verified with Windows 11, PowerShell 7, and Python 3.11. From the repository root, enter the English MCP course:
Set-Location ".\docs-EN\mcp" # Enter the MCP teaching project so that relative example and fixture paths resolve correctly.Run the 54 data cases:
python -B .\examples\validate_mcp_messages.py # Run strict JSON fixture validation; -B prevents local cache files.Run the tests:
python -B .\examples\test_validate_mcp_messages.py # Run unit and data-driven regression tests in normal mode.
python -B -O .\examples\test_validate_mcp_messages.py # Check that safety logic does not incorrectly depend on bare assert statements removable by optimization.
python -B -W error .\examples\test_validate_mcp_messages.py # Turn any Python warning into a failure.
python -B -O -W error .\examples\test_validate_mcp_messages.py # Cover optimized and strict-warning conditions together.-B prevents __pycache__ creation; -W error treats warnings as failures; -O reruns the validator to prove that it did not incorrectly implement security checks as assert statements that optimization can remove.
The expected summary includes:
{
"status": "ok",
"profile": "offline-mcp-teaching-profile-v2",
"protocol_version": "2025-11-25",
"case_count": 54,
"passed": 16,
"expected_failures": 38
}“expected_failures” means that negative samples were rejected correctly; it does not mean the tests failed.
Read the fixture first
mcp-cases.json has eight parts:
schema_version: the fixture structure version, not an MCP version.protocol_version: the stable specification date currently validated by the course.transport_context_defaults: secret-free defaults for HTTP transport type, active token, token audience, and RFC 8707resource.authorization: protected resource, authorization revision, principal/tenant/scope, resource graph, and template policy.client: clientInfo and roots/sampling/elicitation/tasks capabilities.server: serverInfo and tools/resources/prompts/logging/tasks capabilities.tool: one offline weather tool with input/output schemas and optional task support.cases: setup, bidirectional steps, control events, expected pass/fail status, and error fragments for every case.
Every step explicitly states its direction:
{ // The fixture uses an outer direction field to remove ambiguity in bidirectional JSON-RPC.
"direction": "server_to_client", // This request goes from server to client, which determines capability and method-direction checks.
"message": { // Only this inner object is the strict JSON-RPC message.
"jsonrpc": "2.0", // Fixed protocol-version field.
"id": 10, // Request ID; the client should later return a response or error with this same ID.
"method": "roots/list" // The server asks the client to list currently available roots.
} // End inner JSON-RPC request.
}JSONC used for teaching
The outer fixture and inner JSON-RPC message have different responsibilities. Remove the
//comments before using it as strict JSON test data.
Direction is not decoration. roots/list must be a server-to-client request; changing it to client_to_server must be rejected.
How the validator works
1. Strict JSON
Python’s default json.loads accepts duplicate keys by overwriting the earlier value and accepts NaN/Infinity. The project explicitly rejects both, limits fixtures to 2 MB and JSON container nesting to 64 before recursive decoding, reads only “limit + 1 byte,” and does not echo a local path in an I/O error. This prevents a signature, audit trail, or different parser from seeing different values and avoids the false budget of “read all input, then check its size.”
2. JSON-RPC envelope
The validator distinguishes:
- Request: method + ID.
- Notification: method with no ID.
- Response: ID plus exactly one of result/error.
It correlates requests by “sending direction + ID type + ID value.” Integer 5 and string "5" are different; both sides can use ID 5 concurrently; the same sender cannot reuse an ID while an earlier request is unanswered.
A message cannot carry both method and result/error. A successful response is first validated against its pending method, then atomically consumes the pending request. A malformed InitializeResult, tools/list, or other successful result cannot pop correlation state first and leave a later valid response permanently unmatched. A valid JSON-RPC error response does terminate its corresponding request, but does not fabricate a business-success state such as a subscription.
3. Initialize state machine
The states are:
new
→ waiting_for_initialize_response
→ waiting_for_initialized_notification
→ readyOrdinary requests cannot skip the handshake. Response IDs, version, info, and capabilities must match the course profile.
4. Bidirectional capability gates
The validator rejects:
- A client sending
tools/listwhen the server did not declare tools. - A server sending
roots/listwhen the client did not declare roots. - A server sending tool-enabled sampling when the client declares sampling but not
sampling.tools. - A server sending URL mode when the client did not declare
elicitation.url. - A client task-augmenting
tools/callwhen the server did not declaretasks.requests.tools.call.
It also checks Boolean sub-capabilities such as tools.listChanged, resources.listChanged, resources.subscribe, and roots.listChanged.
5. Resources contract and subscription state
The stable profile accepts exactly these five client-to-server methods:
resources/listandresources/templates/list: may take an opaque string cursor; cursor, descriptor, URI template, MIME, annotations, and size in results are checked.resources/read: its requested URI must appear in the current tenant’s explicit resource policy; a result may contain only the requested URI or a child URI explicitly allowed by the policy graph.resources/subscribeandresources/unsubscribe: both require the server to declareresources.subscribe: true.
State changes only after a successful response, not when the request is sent. A failed subscribe does not create a subscription; a failed unsubscribe keeps the old subscription. notifications/resources/updated must correspond to an active subscription for the same principal and tenant. A child relationship comes from an explicit policy graph, not a shared string prefix. notifications/resources/list_changed depends only on resources.listChanged, not on a per-item subscription.
The project locally checks URIs, URI templates, MIME, descriptors, canonical padded Base64, and UTF-8 byte count. One item and the full resources/read.contents share a 64 KiB teaching budget, with at most 64 content items; each list page has at most 256 items. This prevents gates from being bypassed with many empty objects or small chunks. A structurally valid resource is still untrusted data, not a system instruction.
6. Out-of-wire authorization snapshot and revocation
Before every teaching Resources operation, the validator checks the complete transport_context before handling the MCP message:
- The transport is the
streamable_httptransport modeled by this project. - The access token remains active.
- The token audience and the RFC 8707
resourceused in both authorization and token exchange bind exactly to the current MCP protected resource. - The subject exists and is not revoked; tenant and principal remain consistently bound.
- The claims in this request do not exceed policy-granted scopes and include the scope required by the current method.
authorization_revisionis the currently effective revision.
Changing the revision clears active subscriptions first; requests, notification delivery context, and pending success results with an old revision all fail closed. Fixtures neither retain nor validate a real bearer token and do not simulate signatures, issuers, expiration, introspection, 401/403, or WWW-Authenticate. Those belong to real HTTP/OAuth integration tests. stdio does not use this HTTP authorization flow and should obtain credentials from a controlled environment.
7. Tool contract
Input validation checks required fields, types, enums, minLength, and unknown fields. The schema accepts only explicit keywords that the course actually executes and bounds recursion depth, properties, enums, arrays, and string size, avoiding a silent “declared but not executed” schema. If an outputSchema exists, output must contain conforming structuredContent. A successful tools/list result also checks pagination cursor, descriptor, unique tool names, and at most 256 items. An execution error with isError: true may provide only actionable content; a JSON-RPC error represents a protocol-layer failure.
8. Client features and Tasks
- A root response must use a
file://URI. Roots express only a coordination scope and never replace a resource ACL, token scope, or file-system sandbox. - Sampling checks messages,
maxTokens, tool descriptors, and tools/context sub-capabilities. This profile accepts only bounded text content for a successful result; it does not claim to implement a complete multimodal or tool-use schema. - Form elicitation rejects common secret field names; URL mode requires an absolute HTTPS URL in this teaching profile.
- Task augmentation checks capability and tool-level
taskSupport, distinguishesCreateTaskResultfrom a normal tool result, and checks basic bounded structure for everytasks/listTask snapshot: ID, status, timestamp, required TTL, and an optional polling interval.
These checks are a teaching subset. For example, the validator does not implement the full restricted elicitation schema, every content type, SSE framing, task-ownership storage, real OAuth, or the official conformance corpus. tasks/list validates only bounded structural snapshots; it does not prove the current requestor may see the task, check state transitions, or retain task state. Successful schemas for prompts/*, logging/setLevel, completion/complete, and tasks/get/result/cancel are not implemented, so the validator rejects those requests explicitly before creating pending state instead of accepting a success result it cannot validate.
Step-by-step experiments
Change only one fixture case at a time, predict the result, and then run it:
- Reverse the direction of a valid roots request.
- Remove the client’s
sampling.toolswhile preserving a tool-enabled sampling request. - Add an
idto a notification. - Send two unanswered requests with the same ID in the same direction.
- Change tool
unittokelvin. - Remove
structuredContentfrom a successful tool result. - Request
api_keythrough form elicitation. - Remove the server’s
tasks.requests.tools.calland still send a task-augmented tool call. - Change URL elicitation to
http://and observe this profile’s hardening rule. - Change
token_audienceor the RFC 8707resourceto another server. - First subscribe successfully, then switch to
authz-v2and observe subscription invalidation and rejection of old-revision reads. - Change a child URI so that it merely shares a similar prefix with
handbookish.
Restore the fixture afterward and rerun all 108 tests.
Extension tasks
Foundation extension
- Add an integer parameter with
minimum/maximumto the tool, plus positive and negative fixtures. - Add multipage state to
tools/list, cross-page uniqueness, and dynamiclistChangedcache-invalidation checks. - Validate accepted form responses against the originally requested schema.
Advanced extension
- Implement an explicit policy for whether JSON-RPC batch is allowed.
- Create request-token correlation for progress/cancellation.
- Add state machines for task status,
tasks/get,tasks/result, and related-task metadata. - Extend the existing independent transport project with an official SDK adapter, long-connection backpressure, proxy faults, and persistent SSE resume; do not put this logic into message fixtures.
- Write one minimal client and server with official SDKs and integration-test them against this fixture; never rename the teaching validator as official conformance.
For every extension, add a failing test first, then implement the smallest rule.
Troubleshooting guide
| Symptom | Cause | Action |
|---|---|---|
| Fixture root/key error | JSON structure or unknown field | Check the strict schema and duplicate keys |
| “wrong direction” | Method is sent by the wrong side | Compare server/client capability |
| “did not declare capability” | Top-level or sub-capability is missing | Fix the capability or do not send the request |
| Token audience/resource error | Token or RFC 8707 resource was sent to another server | Stop the request and repeat the correct authorization flow |
| Stale authorization revision | Policy was revoked or refreshed while an old identity snapshot was reused | Discard old requests/subscriptions and reauthorize with fresh identity |
| Resource update has no active subscription | Subscribe failed, was unsubscribed, was revoked, or URI is outside the explicit child graph | Resubscribe and inspect the resource graph; do not infer relationship from a string prefix |
| Response has no matching request | ID/direction is wrong or a response is duplicated | Inspect outstanding requests |
structuredContent fails | Output does not match its schema | Fix the server result; do not change only its text |
Tests fail only with -O | Production logic incorrectly uses assert | Separate explicit exceptions from test assertions |
Project acceptance
- The CLI reports 54 cases: 16 positive passes and 38 expected negative rejections.
- All 108 tests pass in normal mode.
- All 108 tests pass with
-O. - All 108 tests produce no warning with
-W error. - All 108 tests pass under combined
-O -W error. - I can explain why direction + ID jointly determine request/response correlation.
- I can give a server-capability, client-capability, and sub-capability gate.
- I can distinguish a tool protocol error from an execution error.
- I can explain the current dynamic/experimental boundary of URL elicitation and Tasks.
- I can explain why capability, Roots, token scope, tenant ACL, and subscription state cannot replace one another.
- I can state that
transport_context, authorization revision, and control events are not MCP wire fields. - No cache, secret, real data, or network side effect was generated.
Self-check
- Why does a rejected negative case count as “passed” in the summary?
- Why can one global set not record request IDs?
- Why must
structuredContentbe checked when an output schema exists? - Why does the validator reject
api_keyin a form while still not proving that elicitation is secure overall? - Which missing evidence prevents this project from being called a protocol conformance test?
- Why may an updated notification not be accepted immediately after a subscribe request has been sent?
- Why cannot a URI prefix prove a sub-resource relationship?
- Why does a token with the correct scope still need audience, resource, tenant, and revocation-revision checks?
At minimum, the answer should mention the full specification schema, real transport, official SDKs, version interoperability, authorization, concurrency/disconnection, performance, and security testing.
Next step
After completing this project, first continue to Loopback Streamable HTTP and OAuth Resource Boundaries for real header/status/session evidence. Then return to the MCP index, choose a server/client tutorial from the official reference layer, and reproduce it with a real SDK. After that, continue to Agent Core and treat MCP as a controlled integration boundary rather than a planner.
References
The following are first-party or original protocol sources, retrieved or checked on 2026-07-21.