# Agent endpoint fix guide (MCP and A2A) A plain Markdown guide for the findings the [Agent Endpoint Inspector](https://jwatte.com/tools/agent-endpoint-inspector/) and the Mega Analyzer's agent-endpoint rows report. Work through it yourself or hand it to an AI coding agent. Each section is the same four steps: diagnose, decide, fix, verify. Replace `https://example.com` with your site. Every command here is read-only: it lists or describes, it never runs a tool or sends a task. Background and the reasoning behind each rule: --- ## Before you start: find your endpoints ```sh curl -s https://example.com/.well-known/mcp/server-card.json curl -s https://example.com/.well-known/mcp.json curl -s https://example.com/.well-known/agent-card.json ``` The MCP card names its endpoint in `remotes[].url` (current shape), `transport.endpoint` or `endpoint` (older drafts). The A2A card names its endpoint in `supportedInterfaces[].url` (A2A 1.0) or `url` (0.3). If a card declares only a `webmcp` transport, the tools run in the visitor's browser and there is no HTTP endpoint: none of the HTTP checks below apply. Below, `$MCP` is your MCP endpoint and `$A2A` your A2A endpoint: ```sh MCP=https://example.com/mcp A2A=https://example.com/a2a ``` --- ## 1. MCP endpoint answers the handshake **Diagnose** ```sh curl -s -X POST "$MCP" \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"check","version":"1"}}}' ``` A healthy server returns a JSON-RPC `result` with `protocolVersion`, `serverInfo` and `capabilities`, as plain JSON or as an event stream with a `data:` line. **Decide** - 404 at an advertised URL: the card may point at an endpoint that does not exist. Either deploy the server or remove the card. An advertised endpoint that does not answer is worse than none, because clients that find the card try it and fail. - 403 with an "Invalid Origin" style error: your server refuses requests that carry an unknown `Origin`. That is correct for browsers (the spec asks servers to validate `Origin`), but desktop and server-side clients send no `Origin` at all. Make sure a request without `Origin` is accepted. - 3xx: a redirect turns a POST into a GET and drops the body. Serve the endpoint at the exact URL the card names. **Verify**: the command above returns a `result`, then `tools/list` returns your tools: ```sh curl -s -X POST "$MCP" -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ -H 'mcp-protocol-version: 2025-11-25' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' ``` If the first call returned an `Mcp-Session-Id` header, send it back on the second call as `-H 'mcp-session-id: '`. --- ## 2. GET answers with a stream or 405 **Diagnose** ```sh curl -s -i "$MCP" -H 'accept: text/event-stream' --max-time 3 | head -n 15 ``` **Decide**: the Streamable HTTP transport allows exactly two answers to GET: an event stream (`Content-Type: text/event-stream`) if your server pushes messages without a request, or `405 Method Not Allowed` if it does not. A read-only, stateless server should answer 405. A 200 HTML page, a 404 or a redirect confuses clients that probe with GET. **Fix**: route GET (and DELETE, PUT, PATCH, HEAD) on the endpoint to a 405. Netlify Functions v2 / any `Request -> Response` handler: ```js if (request.method !== 'POST' && request.method !== 'OPTIONS') { return new Response(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Method not allowed: this MCP endpoint takes JSON-RPC by POST.' } }), { status: 405, headers: { 'Content-Type': 'application/json', Allow: 'POST, OPTIONS', 'Access-Control-Allow-Origin': '*' } }); } ``` **Verify**: the diagnose command shows `HTTP/... 405` and an `Allow` line. --- ## 3. 405 carries an Allow header RFC 9110 requires every 405 to list the methods the resource supports in `Allow`. Add `Allow: POST, OPTIONS` to the 405 (see the snippet above). Same rule for the A2A endpoint. **Verify** ```sh curl -s -o /dev/null -D - "$MCP" | grep -i '^allow:' curl -s -o /dev/null -D - "$A2A" | grep -i '^allow:' ``` --- ## 4. Preflight matches what the endpoint does **Diagnose** ```sh curl -s -o /dev/null -D - -X OPTIONS "$MCP" \ -H 'origin: https://example.org' \ -H 'access-control-request-method: POST' \ -H 'access-control-request-headers: content-type, mcp-protocol-version' | grep -i '^access-control\|^allow\|^HTTP' ``` **Decide**: if `Access-Control-Allow-Methods` lists `GET` while GET answers 405, the two headers disagree. Browsers do not care (GET, HEAD and POST are CORS-safelisted, so the method list only matters for PUT, PATCH, DELETE and the like), but people and scanners read the preflight literally and will try GET. **Fix**: make the preflight list the same methods as `Allow`: ```js if (request.method === 'OPTIONS') { return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Accept, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id, Last-Event-ID', 'Access-Control-Max-Age': '86400' }}); } ``` For A2A, allow `Content-Type, Accept, A2A-Version, A2A-Extensions`. If you deliberately refuse browser origins (a 403 or no `Access-Control-Allow-Origin`), that is a choice, not a fault: desktop and server-side clients are unaffected, and only browser-based agents cannot connect. **Verify**: the diagnose command shows `Access-Control-Allow-Methods: POST, OPTIONS`. --- ## 5. Card protocol claims match the server **Diagnose**: compare the card's `supportedProtocolVersions` with what the server answers. - 2025-era revisions (`2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`) use the `initialize` handshake: the version in its `result` is what the server speaks. - `2026-07-28` has no handshake. Every request carries its version in `params._meta` and in headers. Test it with `server/discover`, not with `initialize` (an `initialize` asking for 2026-07-28 correctly gets an older version back): ```sh curl -s -X POST "$MCP" -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ -H 'mcp-protocol-version: 2026-07-28' -H 'mcp-method: server/discover' \ -d '{"jsonrpc":"2.0","id":3,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"check","version":"1"},"io.modelcontextprotocol/clientCapabilities":{}}}}' ``` **Fix**: generate the card from the same code and config the server runs, so the version list cannot drift. If your server does not implement 2026-07-28, remove it from the card. **Verify**: every version the card lists answers, through the dialect that version uses. --- ## 6. Server card version matches the server The card's `version` should equal `serverInfo.version` from `initialize`. Bump the server version when tools, config or the server code change, then regenerate the card from that same config. If you publish both optional discovery paths, serve identical bytes at `/.well-known/mcp/server-card.json` and `/.well-known/mcp.json`. ```sh diff <(curl -s https://example.com/.well-known/mcp/server-card.json) <(curl -s https://example.com/.well-known/mcp.json) && echo same ``` --- ## 7. Every tool declares whether it changes anything **Diagnose**: in the `tools/list` result, each tool's `annotations` should carry `readOnlyHint` (and, for tools that write, `destructiveHint` and `idempotentHint`). **Fix** ```js { name: 'get_hours', description: 'Opening hours for each location.', inputSchema: { type: 'object', properties: {}, additionalProperties: false }, annotations: { title: 'Opening hours', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } } ``` Clients use these hints to decide which calls need the user's confirmation. Without them, a careful client treats every tool as possibly destructive. The hints are your statement about your own tools; they are not enforcement, so make them true. Never mark one tool both `readOnlyHint: true` and `destructiveHint: true`. A tool that only reads cannot destroy anything, so one of the two is wrong, and a client cannot tell which. Decide what the tool really does and keep the hint that is true. --- ## 8. A2A endpoint answers GET with 405 + Allow **Diagnose** ```sh curl -s -i "$A2A" | head -n 12 ``` **Decide**: a JSON-RPC A2A endpoint normally answers GET with 405. A 404 usually means the agent card names an endpoint that is not deployed: deploy it, or fix the URL in `supportedInterfaces`. **Fix and verify**: same 405 + `Allow: POST, OPTIONS` pattern as section 2, and the same preflight rule as section 4. --- ## 9. Agent card has the required fields An A2A 1.0 agent card needs at least `name`, `description`, `version`, `supportedInterfaces` (each with `url`, `protocolBinding` and `protocolVersion`), a `capabilities` object (it may be `{}`), `defaultInputModes` and `defaultOutputModes` (media types such as `"text/plain"`), and a non-empty `skills` list. Serve it at `/.well-known/agent-card.json`. The older `/.well-known/agent.json` name is still read by some clients, but current scanners look for `agent-card.json`. Each skill needs an `id` (unique on the card), a `name`, a `description` and at least one entry in `tags`; `examples` help an agent choose it. ```sh curl -s https://example.com/.well-known/agent-card.json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const c=JSON.parse(s);for(const k of ["name","description","version","capabilities","defaultInputModes","defaultOutputModes","skills"])if(c[k]==null||(Array.isArray(c[k])&&!c[k].length))console.log("missing",k);const ids={};for(const sk of c.skills||[]){if(!(sk.tags||[]).length)console.log("skill without tags:",sk.id);if(ids[sk.id])console.log("duplicate skill id:",sk.id);ids[sk.id]=1}})' ``` --- ## 10. Tool definitions are well formed **Diagnose**: in the `tools/list` result, every tool should have: - a `name` of 1 to 128 characters using only letters, digits, `_`, `-` and `.`, and no other tool on the server with the same name (clients key tools by name, so a duplicate hides the other); - a non-empty `description` (a model chooses tools by their descriptions); - an `inputSchema` that is a JSON Schema object of type `object`. ```sh curl -s -X POST "$MCP" -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | head -c 2000 ``` **Fix**: a tool that takes no parameters still needs an object schema. The recommended form accepts only an empty object: ```js inputSchema: { type: 'object', properties: {}, additionalProperties: false } ``` Rename any tool with spaces or other punctuation in its name (`get hours` becomes `get_hours`), and give every tool one plain sentence saying what it returns. --- ## 11. Server card lists the tools the server serves If your server card has a `tools` list, it is a promise about the server. It should name exactly the tools `tools/list` returns. Generate both from one list, the same way as the version in section 6, so adding or removing a tool updates both at once. --- ## 12. Answers ping `ping` is the protocol's no-op liveness check: the spec says the receiver MUST answer it promptly with an empty result. Clients use it to tell a slow server from a dead one. ```sh curl -s -X POST "$MCP" -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' # expect: {"jsonrpc":"2.0","id":1,"result":{}} ``` **Fix**: in your JSON-RPC dispatcher, answer `ping` before the "method not found" fallback: ```js if (msg.method === 'ping') return { jsonrpc: '2.0', id: msg.id, result: {} }; ``` --- ## 13. A notification gets 202 with no body A notification (any message with no `id`, such as `notifications/initialized`) has nothing to answer. Over Streamable HTTP the server MUST reply `202 Accepted` with an empty body. ```sh curl -s -i -X POST "$MCP" -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' | head -n 5 # expect: HTTP/1.1 202 Accepted, and no body ``` **Fix**: when every message in the request is a notification, return `new Response(null, { status: 202 })` (plus your CORS headers) instead of a JSON body. --- ## 14. Endpoints use https The MCP endpoint and every A2A interface URL should be `https://`. Over plain `http://` anyone on the network path can read and change the traffic, including the tool list a model decides to trust. Serve the endpoint over https, and redirect plain http requests for your pages to https (but never redirect the endpoint itself: a redirect turns a POST into a GET and drops the body, so point the cards straight at the https URL). --- ## After every fix Re-run the inspector and confirm each item passes: Free, no signup. The inspector is read-only: it lists capabilities and never runs one. ## Evidence, authentication and coverage A 401 is authentication required, not a broken protocol. A 403, timeout, rate limit or capped response is inconclusive. Keep the challenge and authentication controls. Verify privately with an authorized client; do not add credentials to a public audit URL. HTML at `/mcp` is claimed-only, not protocol validation. Check Agent Card metadata independently of service reachability. A2A 0.2/0.3 cards use their declared revision and legacy URL/transport fields. Current 1.0 interfaces declare URL, protocol binding and protocol version. Unsupported revisions remain unknown. Required-field checks do not certify the full schema or skill truthfulness. Operation labels come from untrusted names and descriptions. A search that advertises holding inventory is not authorized for anonymous execution. Verify personal-record tools in an owner-controlled fixture environment with synthetic data and denied anonymous access. No public scanner call proves exposure here. Download the evidence JSON for UTC timestamps, bounded content hashes, redacted excerpts, URLs, status and limitations. Unknown checks lower coverage; they never become passes. Do not compare changed scopes as identical scans. Page facts, booking handoffs, lodging policies and live supplier quotes need their own evidence. Weekly pricing, stay caps and tenancy applications are not proof of hotel inventory or instant booking. Acceptance: the affected protocol exchange works for an authorized client; unrelated controls and scan limits remain intact. Rollback: revert the routing or metadata change through version control, then repeat the bounded inspection. See [the evidence article](https://jwatte.com/blog/agent-endpoint-evidence-before-verdicts/).