typescript-sdk

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

35posts · 30d
Nov 41 post
  1. @hecodesforme @mattzcarey Tool arguments/results and prompt arguments are excluded in the merged PR. Error messages and resource URIs are recorded, though: typescript-sdk#3115

    1 like · 39 views
  2. Otel in the client and server love it typescript-sdk#3115

    1 like · 71 views
  3. if some otel experts wanna get your agents to shout at my PR that would be awesome. typescript-sdk#3115 aiming to get this in the ts sdk asap

    2 likes · 717 views
  4. MCP TypeScript SDK bearer auth skipped audience checks, so a token a shared auth server issued for another service was accepted. Upgrading alone changes nothing: set expectedResource. github.com/modelcontextprotocol/typescript-sdk/se…

    1 like · 1 repost · 14 views
  5. Want your AI app to use custom tools? 🔌 The MCP TypeScript SDK provides server and client libraries for tools, resources and prompts. Build a small integration to start. 🤖 modelcontextprotocol/typescript-sdk #MCP #浇朋友 #蓝朋友 #互关 #fo

    1 like · 69 views
  6. MCP Step-Up Authorization vs. OAuth Incremental Authorization: What Actually Changes? MCP Step-Up Authorization vs. OAuth Incremental Authorization: What Actually Changes? > “There are no zones.” There were three. TL;DR When Cloudflare announced step-up authorization for its API MCP Server, I wanted to know how it differed from Google OAuth's Incremental Authorization. I read the specs, wrote my own MCP client, and sent 128 requests to the live service. The additional authorization itself is still ordinary OAuth. The difference is a path for the server to tell the client, “You don't have enough permission.” - The client remembers previously granted scopes. Cloudflare's authorization server does not carry over previously granted scopes outside the baseline set. This is the opposite of Google's include_granted_scopes behavior. - But Cloudflare's server remembered them too. The scope in a 403 challenge followed the scopes granted to that token. That is the approach MCP removed in the 2026-07-28 specification. - The challenge path did not cover every operation. On the same server, some insufficiently authorized operations returned a 403 challenge, while others returned 200 with an empty list. - I reported the third finding to Cloudflare, and it was fixed that same day. The follow-up is in §13. Here is what happened when I called the same APIs before and after granting scopes: | Operation | Before granting scopes | After granting scopes | | --- | ---: | ---: | | List zones | 0 items | 3 items | | List Workers scripts | 0 items | 12 items | | List D1 databases | 403 challenge | 5 items | Test code: GitHub repository Let me state the main point upfront. Step-up Authorization does not introduce a new way to authorize. It introduces a way to communicate that a scope is missing. In a traditional web app, developers know which feature needs which scope. With MCP, an AI chooses tools, so the client may not know in advance. The server has to notice that permission is missing, and it needs a way to tell the client. When that path works, a client can start with minimal permissions. When it doesn't, the AI simply reports that something “doesn't exist.” > This article expands on my lightning talk, “How Is MCP Step-up Authorization Different from OAuth Incremental Authorization?”, with measurements I couldn't fit into the talk and the details of a conclusion I had to retract once. It is for readers who know the basics of OAuth 2.0 and may build MCP servers. --- 1. The third step in a three-part rollout On October 19, 2026, Cloudflare posted this in its Changelog: > Grant only the required scopes when you connect, then authorize more on demand as your agent performs a task. When a tool call needs a scope you have not granted, the server returns an insufficient_scope challenge naming it, and your MCP client asks you to approve it. Scopes you already granted are kept. Start with the minimum, then add permissions when needed. When I read that, I couldn't tell how it differed from include_granted_scopes=true, which I already use in my own projects. That question started this investigation. The announcement was not an isolated change. It was the third step in a rollout: | Date | Event | | --- | --- | | August 22, 2026 | Wrangler and the API MCP Server added optional scopes and “Edit Permissions” on the consent screen | | October 1, 2026 | workers-oauth-provider v1 made step-up possible with a one-line insufficientScope() call | | October 19, 2026 | The API MCP Server added step-up authorization | The August Changelog ended with this instruction: > If a command or tool call needs a scope that you declined, reauthorize the client and grant that scope. At that point, if you declined a permission, you had to reconnect manually. The ability to grant fewer permissions came with extra friction. The October change removed that manual step. 2. Two versus 384 Cloudflare's MCP server publishes two different scopes_supported lists. They have the same name, but different RFCs define them for different purposes. curl -s https://mcp.cloudflare.com/.well-known/oauth-protected-resource/mcp curl -s https://mcp.cloudflare.com/.well-known/oauth-authorization-server | Metadata | RFC | Count | Contents | | --- | --- | ---: | --- | | Protected Resource Metadata | RFC 9728 | 2 | user:read, account:read | | Authorization Server Metadata | RFC 8414 | 384 | dns.write, zone.write, workers-r2.read, … | The first list says what is minimally needed to access this resource. The second is the full catalog of scopes this authorization server can issue. The MCP specification describes the first as the minimal set of scopes necessary for basic functionality. Without authentication, the server returns a 401 naming those two scopes: HTTP/2 401 www-authenticate: Bearer realm="OAuth", resource_metadata="https://mcp.cloudflare.com/.well-known/oauth-protected-resource/mcp", scope="user:read account:read" The gap between 2 and 384 is why this feature exists. Without step-up, the client has to choose from 384 scopes up front. Request too few and things don't work; request too many and you overgrant access. Starting with just two scopes is practical only if you can add more when you discover you need them. 3. Step-up builds on RFC 6750 In the MCP specification, step-up starts with a 403 insufficient_scope response: HTTP/1.1 403 Forbidden WWW-Authenticate: Bearer error="insufficient_scope", scope="files:write", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource", error_description="File write permission required for this operation" MCP did not invent this error code. It was defined in RFC 6750 §3.1 in 2012: > insufficient_scope — The request requires higher privileges than provided by the access token. What MCP adds is the procedure that turns this response into reauthorization: 1. Parse WWW-Authenticate. 2. Determine the required scopes as “the scopes requested last time ∪ the scopes in the challenge.” 3. Reauthorize. This is the normal OAuth authorization-code flow. 4. Retry the original request. Compared with Google OAuth Incremental Authorization, the differences come down to three questions: | | Google Incremental Authorization | MCP Step-up Authorization | | --- | --- | --- | | Who notices that more access is needed? | The application, before it calls the API | The server | | How is the missing scope communicated? | No challenge; the app already knows | WWW-Authenticate: scope="…" | | Who remembers previously granted scopes? | The authorization server (include_granted_scopes=true) | The client | | How does reauthorization work? | OAuth authorization-code flow | The same flow | The third row cannot be resolved by reading the specification alone. The Changelog says “Scopes you already granted are kept,” but leaves out the subject. The client may be taking a union, or the authorization server may be remembering the earlier grant. Both interpretations fit. Determining which one was true in the live service became the center of my test. > Terminology: baseline > In this article, baseline means the minimal scopes named by Protected Resource Metadata (user:read and account:read) plus offline_access. Cloudflare's consent screen presents these three every time, regardless of previous grants. 4. It was not a new feature in the 2026-07-28 spec The Changelog linked to step-up in MCP 2026-07-28, so I assumed it was new. It wasn't. The “Step-Up Authorization Flow” section was already in the 2025-11-25 specification. It was absent before 2025-06-18. What changed in 2026-07-28 was who creates the union of scopes: | Specification | Who creates the union? | | --- | --- | | 2025-11-25 | The server | | 2026-07-28 | The client | The 2025-11-25 version gave servers three choices: Minimum, Recommended, and Extended. All three assumed that the server would include previously granted scopes in the 403 challenge. The 2026-07-28 version removed all three choices. The reason is in Issue #2349: > This contradicts the RFC, creates an impossible requirement for stateless servers, and leads to infinite re-authorization loops in practice. TypeScript SDK Issue #1582 demonstrates the loop. Imagine a server where initialize requires init, while tools/list requires mcp:tools:read: sequenceDiagram participant C as MCP Client participant S as MCP Server C->>S: tools/list (token: init) S-->>C: 403 scope="mcp:tools:read" Note over C: Overwrites _scope; loses init C->>S: tools/list (token: mcp:tools:read) S-->>C: 200 C->>S: initialize (token: mcp:tools:read) S-->>C: 403 scope="init" Note over C: Overwrites _scope again; loses mcp:tools:read Note over C,S: The loop continues If a 403 faithfully returns only the scope required by that operation, the previous permission disappears as soon as the client overwrites its scope list. A union is not just good manners; it prevents this failure mode. 5. I wrote a client without the SDK I did not use @modelcontextprotocol/client for this test. As soon as that library sees a 403, it calls computeScopeUnion() and automatically unions the scopes before reauthorization: function computeScopeUnion(...scopes) { const seen = new Set(); for (const scope of scopes) { if (!scope) continue; for (const token of scope.split(/\s+/)) if (token) seen.add(token); } return seen.size > 0 ? [...seen].join(" ") : void 0; } That is a correct implementation. But it means I cannot test reauthorization without a scope union. It would be impossible to isolate who is preserving the previous scopes. So I wrote a minimal client that manually performs OAuth and MCP requests. Being able to choose the requested scopes each time was essential to this test. I did not need to pre-register a client. Cloudflare's authorization server supports Dynamic Client Registration, so the script can register itself as a public client. It does not receive a client_secret. The transport does not throw on HTTP errors. A 400 or 403 is an observation, so the client records and returns the status, headers, and body instead of swallowing them. Every request passes through one hook and is logged as one line of JSON per round trip. Secrets are replaced with fingerprints, not removed entirely: "access_token": { "_redacted_len": 82, "_sha256_16": "184d68d85f68caab" } Replacing a value with <redacted> makes it impossible to show whether the same token was sent repeatedly or a new one was issued each time. A fingerprint shows both without disclosing the token. I later used matching fingerprints sent to two resources as evidence that they were the same token. Two requested scopes, three granted The consent process has two screens. First, you choose a preset that includes “Minimal.” Then you see a breakdown of the requested permissions. The Changelog's instruction to “choose Minimal on the consent page” refers to the first screen. The second screen has REQUIRED and ADDITIONAL ACCESS sections. Required permissions cannot be removed. Individual optional permissions can be unchecked through Edit Permissions. This is where the optional-scope change from August appears. I show the actual screen in §7. I requested two scopes, user:read account:read, but received three: Requested: user:read account:read Granted: user:read offline_access account:read I briefly wondered why I had an offline_access scope I never requested. The screen explained it: Background Access was listed as REQUIRED. This made a basic lesson matter from the first test: record requested scopes and granted scopes separately. 6. The challenge scope included the baseline To trigger 403 responses, I tried read-only endpoints systematically. The OpenAPI specification returned by the search tool includes x-api-token-group, whose description says: > API token permissions the endpoint accepts (any one); OAuth scopes have the same names This lets you derive the mapping between scopes and endpoints from the server itself. For example, GET /zones lists Zone Zone Read. When I called an operation requiring a scope I did not have, I got this challenge: HTTP/2 403 WWW-Authenticate: Bearer realm="OAuth", error="insufficient_scope", scope="user:read account:read workers-r2.read", resource_metadata="https://mcp.cloudflare.com/.well-known/oauth-protected-resource/mcp", error_description="This operation needs the workers-r2.read scope" The scope includes the baseline. This is where Cloudflare's implementation and its own documentation diverged from the MCP specification: | Source | What to include in the 403 scope | | --- | --- | | MCP 2026-07-28 specification | Only the scope needed by the operation is sufficient; including previous scopes is not required | | workers-oauth-provider documentation | Include all scopes, including the baseline | | Live service | The latter | The error_description explicitly names the missing scope, which is also helpful for a human reading the response. 7. Who remembers? I got this wrong once This is the central question. I requested only d1.read, deliberately dropping the three scopes I already had: Requested: d1.read Granted: d1.read user:read offline_access account:read !Consent screen after requesting only scope=d1.read. D1 Read and Account Read appear under ADDITIONAL ACCESS, with 4 total permissions. The three scopes I had not requested were still there. I almost wrote, “The authorization server preserves previous grants.” That was wrong. There were three possible explanations, and this observation could not distinguish between them: 1. The authorization server preserved previously granted scopes. 2. I had included scope: "user:read account:read" during DCR registration. That was my own confounding factor. 3. The resource simply adds its baseline scopes by default. I had introduced the second confound myself. So I created a control: a brand-new client with no previous grants, registered with only d1.read, and opened the same consent screen. !Consent screen for a client with no previous grants. It is identical to the first screen. The screens were identical: four permissions, User Read and Background Access under REQUIRED, and D1 Read and Account Read under ADDITIONAL ACCESS. Account Read appeared even though this client had never been authorized before. So explanation 3 accounted for it. I retracted my conclusion. I redesigned the test: start with a non-baseline scope, d1.read, then request only workers-r2.read. The default baseline could no longer explain the result. D1 Read was absent from the consent screen, even though it had been granted moments earlier. !Consent screen after requesting only scope=workers-r2.read. Developer Platform shows only Workers R2 Storage Read; D1 Read is absent. After approval, the grant was: Requested: workers-r2.read Granted: workers-r2.read user:read offline_access account:read d1.read was gone. I verified the behavior rather than relying on the text alone: the D1 list endpoint returned a 403 challenge again. The capability itself had been removed. > Cloudflare's authorization server does not carry over previously granted scopes outside the baseline. > The Changelog's statement that “Scopes you already granted are kept” is true because the client creates a union. That is structurally the reverse of Google's include_granted_scopes, where the authorization server combines the scopes. 8. The server remembered them too—the approach the spec dropped I changed the scopes in the token and observed the challenge several times: | Scopes held | Operation | Challenge scope | | --- | --- | --- | | Baseline only | List R2 | user:read account:read workers-r2.read | | Baseline + workers-r2.read | List D1 | user:read account:read workers-r2.read d1.read | | Baseline + workers-r2.read | List KV | user:read account:read workers-r2.read workers-kv-storage.read | The challenge was not a fixed baseline. It followed the scopes granted to the token. In other words: challenge.scope = scopes already granted to token ∪ scopes required by operation This is the “Recommended approach” from the 2025-11-25 specification—the approach removed in 2026-07-28. Issue #2349 said this was “impossible” for stateless servers because the server needs to know what scopes the token contains to build the union. Cloudflare shares state between its authorization server and resource server, so it does not have that limitation. One important consequence is that older clients keep working. @modelcontextprotocol/sdk@1.32.1 overwrites _scope in its 403 handler: if (error === 'insufficient_scope') { if (scope) { this._scope = scope; // Overwrites the value; previous scopes are lost } That is exactly the condition for the loop in §4. It still works against Cloudflare because the server builds the union on the client's behalf. The specification puts the responsibility on the client, but this implementation currently does both. 9. “There are no zones” While testing endpoints systematically, I found operations that did not return a 403: | Operation | Required scope | Result | | --- | --- | --- | | R2 / KV / D1 / Queues / Images | Each resource's *.read scope | 403 + challenge | | List zones | zone.read | 200 + empty list | | List Workers scripts | workers-scripts.read | 200 + empty list | | /user/tokens | Not in the catalog | 403 in the tool result text | For the two middle rows, I could not initially tell whether permissions filtered the results or the account really had zero items. I granted zone.read and workers-scripts.read, then repeated the same calls: List zones: 0 items → 3 items List Workers scripts: 0 items → 12 items The resources existed in the account. It was the same account and client; I changed only the scopes. These operations fall outside the step-up path. Since they do not return a 403, the client does not learn that it lacks permission. It never gets a chance to ask the user for additional authorization. The AI says, “There are no zones,” and the conversation ends there. /user/tokens fails differently: > Cloudflare API error: GET /user/tokens returned HTTP 403: 9109: Unauthorized to access requested resource. No OAuth scope covers this endpoint, so OAuth connections cannot use it. Connect with an API token that has one of these permissions instead: API Tokens Write, API Tokens Read. There is no corresponding OAuth scope in the catalog, so there is no way to step up. This is an honest implementation: the reason is returned as text that both the AI and the user can read. Within one server, I found three different ways an authorization failure can be represented. This difference matters when you design how to give an agent access. Only operations that return a challenge can recover on their own after starting with minimal permissions. 10. Refresh cannot add scopes The reason additional authorization has to return to the browser is in RFC 6749 §6: a refresh token cannot be used to broaden its scope. I tested it directly: | Request | HTTP | Granted scopes | | --- | --- | --- | | Omit scope (control) | 200 | Same original seven | | Broaden (workers-kv-storage.read added) | 200 | Same original seven | | Narrow (request baseline only) | 200 | Same original seven | The broader request follows the RFC. The extra scope is silently ignored rather than rejected. I confirmed this behaviorally too: the KV list still returned a 403. The narrowing result surprised me. RFC 6749 §6 does not prohibit narrowing, and the workers-oauth-provider docs say, “Token and refresh requests can only narrow the grant.” In the live service, the request was ignored and d1.read still worked. The library source implements narrowing correctly: let tokenScopes = this.downscope(body.scope, grantData.scope); private downscope(requestedScope, allowedScopes) { if (!requestedScope) return allowedScopes; return requestedScopes.filter((scope) => allowedScopes.includes(scope)); } The docs accurately describe the library. The mismatch is in the hosted service. The challenge header matches insufficientScope() in workers-oauth-provider, but refresh-scope handling and token validation are different. Do not assume that an OSS library and a hosted service with the same name behave identically. I also found two related details: refreshes are serialized, and overlapping requests return 429 temporarily_unavailable; and the refresh_token rotates on every refresh. I wrote a conclusion without having measured it At first, I sent the three refresh cases back to back. The second and later requests failed with 429, so the requests that were supposed to test the main point never ran. Yet the script printed “scope was not broadened.” An empty diff did not mean that was the service's behavior; the request had not reached the relevant code. I retracted the result and reran the tests with delays. I changed the script to treat 429 as “not measured yet” and retry with exponential backoff. Do not mistake a conclusion printed by a script for an observation. 11. The 2026-07-28 protocol has no initialize This was the first implementation snag. I sent initialize and got a 400 response: -32020 the request headers and body disagree: an initialize request (legacy handshake) was sent with a modern MCP-Protocol-Version header -32602 the MCP-Protocol-Version header names protocol revision 2026-07-28, but the request is missing the required per-request envelope key(s): _meta -32020 the body names method tools/list but the required Mcp-Method header is absent The 2026-07-28 protocol is stateless and has no initialize handshake. Instead, every request carries this in params._meta: { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}, "io.modelcontextprotocol/clientInfo": { "name": "…", "version": "…" } } } It also repeats the request body information in headers: Mcp-Method: tools/call and Mcp-Name: <tool name>. That lets an intermediary route or rate-limit requests without opening the body—a useful design for a company that operates a CDN. The Claude Code binary does the same thing. The legacy initialize still works if you omit the MCP-Protocol-Version header. I confirmed both 2025-11-25 and 2026-07-28 return 200 with the same token. > Aside: a 401/403 mix-up > This is not directly related to step-up, so I will keep it short. A randomly generated 43-character string that had never been issued returned 403 insufficient_scope instead of 401 invalid_token. Binary search narrowed the boundary to exactly 40 characters: strings of 40 or more [A-Za-z0-9_-] characters got a 403; 39 or fewer characters, or strings containing ., :, or spaces, got a 401. > > Since 403 insufficient_scope triggers step-up in MCP, I wondered whether an expired client might be prompted to request more permissions. It did not. I tested three realistic cases, and all returned 401: > > | State a real client might have | Result | > | --- | --- | > | A real, revoked token | 401 invalid_token | > | A valid token for another resource | 401 invalid_token | > | A malformed string / 39 characters or fewer | 401 invalid_token | > > Only a deliberately constructed, never-issued string returned 403. It is an implementation flaw, but it has no practical impact. > > I also tested revocation granularity. Revoking an access token leaves the refresh token alive. Only revoking the refresh token results in invalid_grant "Grant not found". 12. So what actually changes? There are three differences. Everything else is still OAuth: 1. Who notices that permission is missing? It moves from the application to the server. 2. Is there a way to communicate it? It moves from none (because the app already knows) to WWW-Authenticate. 3. Who remembers previously granted scopes? It moves from the authorization server to the client. Reauthorization itself is the authorization-code flow in both cases. Step-up does not replace OAuth; it is an entry point back into OAuth. Testing also made it clearer why MCP needs this. In a traditional web app, developers know which scopes each feature needs. With MCP, the AI selects a tool based on the user's request, so the client may not know in advance. With a tool like execute() that can call an arbitrary API internally, the needed permission may not be known until the tool runs. The server is the only component that can notice, so it needs a way to communicate that fact. Looking at the implementation, the responsibility that the spec assigns to one side was held by both sides in practice: the client builds a union, and the server returns one too. That is a sensible transition strategy. Older clients do not break. The remaining issue is in §9. When an operation does not return a 403, the AI cannot tell that permission is missing. This is less a Cloudflare-specific flaw than a mismatch between permission-filtered lists and AI agents. APIs have returned empty lists for a long time; a human might guess that their permissions hide the results. AI does not infer that. It reports that nothing exists. As an MCP server author, I took away one thing: do not silently return an empty list when permissions are missing. Return a 403 challenge, and the client can ask the user. The specification provides the path. Whether an operation uses it is up to the server implementation. 13. Update: I reported it, and it was fixed that day (2026-10-21) After publishing the article, I reported the §9 issue to cloudflare/mcp as Issue #302, titled “A list filtered down to nothing by permissions never raises a scope challenge.” Ten minutes later, I opened PR #304. Two minutes after that, Cloudflare opened PR #305. | Time (UTC) | Event | | --- | --- | | 14:41 | Issue #302 | | 14:51 | PR #304 (mine) | | 14:53 | PR #305 (Cloudflare) | | 22:11 | #305 merged | This was not a retrospective rewrite: both PRs were opened almost simultaneously. #305 was the one that merged, with this note: > Fixes #302, building on @tanahiro2010's report and branch. The merged commit includes my name in Co-authored-by. I expected the report might be accepted and fixed, but I did not expect the fix to build on my branch and credit me as a co-author. One goal accomplished: becoming a contributor to some Cloudflare repository while still a student. Well, I guess maintainer is next… What changed Lists empty because of missing permissions are now treated as refusals. GET /zones, which previously returned 200 with an empty list, now returns 403 and a WWW-Authenticate challenge. The same applies to execute, endpoint tools, and forge tools. Of the three failure modes in the article's original table, the second now matches the first: | Operation | At publication | After #305 | | --- | --- | --- | | R2 / KV / D1 | 403 + challenge | Unchanged | | List zones / Workers scripts | 200 + empty list | 403 + challenge | | /user/tokens | 403 in result text | Unchanged | A useful implementation detail One thing I liked in the implementation is that it does not treat every empty list as suspicious. authorize() in src/api-permissions.ts returns one of granted, missing, unreachable, direct, or unlisted. refusalFor() in src/api-refusals.ts converts an empty list into a refusal only when the result is missing. It does not challenge a list that contains even one item. That means accounts that really have zero items are not caught up in this. The challenge applies only when the connection has none of the scopes accepted by that endpoint. If the account really is empty, the user is asked about scopes once and the flow ends. The refusal message is also careful: it does not claim that Cloudflare returned a 403. It says that the server returned 200 and an empty list, then adds the challenge without misrepresenting the response. The naming mismatch I mentioned in §6 was fixed too. Aliases now map values such as Zone Zone Read from x-api-token-group to their catalog scope names. Without that mapping, the refusal could incorrectly tell users that no OAuth scope covers the endpoint. That is what GET /zones reported before my issue. The tests record and classify responses for 549 endpoints. tests/filtered-lists.test.ts adds 292 lines, and the test fails against main before the fix. Where this article stands I am keeping §§9 and 12 as a record of what I observed at publication. If you call mcp.cloudflare.com now, the zone list no longer returns an empty array. The broader conclusion still holds: whether an MCP server silently returns an empty list or a 403 challenge when permission is missing is the server author's decision. The spec provides the path, but the implementation decides whether to connect it. Cloudflare has now connected it. References - Cloudflare API MCP server now supports step-up authorization — The announcement that started this investigation. Add /index.md to read it as Markdown. - Choose OAuth scopes for Wrangler and the Cloudflare API MCP server — Optional scopes and Edit Permissions on the consent screen. - MCP Specification 2026-07-28 — Authorization — Step-Up Authorization Flow and Scope Challenge Handling. - MCP Specification 2025-11-25 — Authorization — The version with three server-side union strategies. - Issue #2349 — Why responsibility moved to clients; the issue body explains the three arguments. - typescript-sdk#1582 — Reproduction of the infinite reauthorization loop. - cloudflare/workers-oauth-provider — insufficientScope() and the two scopes_supported lists. - Google — Using OAuth 2.0 for Web Server Applications — include_granted_scopes and combined authorization. - RFC 6750 §3.1 — Definition of insufficient_scope. - RFC 9470 — Step-up Authentication, which is a different concept. - cloudflare/mcp Issue #302 — The report from §9, filed after publication. - cloudflare/mcp PR #305 — The merged fix; authorize() and refusalFor() are worth a look.

    5 reactions
  7. An Allow Rule for MCP Server 'a' Also Matched 'a__b'. How Claude Code's Names Collided - Book: System Design Pocket Guide: Fundamentals - The series: Pocket Guides for Developers — 8 short books on agents, RAG, prompts, observability, events, databases and system design — the whole series - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- You wrote "mcp__a" in your allow list because you trust server a. Before Claude Code 2.1.299, that rule also let Claude Code call a different server, named a__b, without asking you. Claude Code 2.1.299 shipped on October 14, 2026, with 131 bug fixes. One of its changes reads: "Changed MCP allow rules to reach only the server they name: mcp__a no longer allows a server named a__b or a_; write mcp__<server>__* with the server's whole normalized name." Another line gives agent tools: lists the same treatment, and in the bug fixes, deny and ask rules for a server whose name contains a double underscore now apply. Those three lines describe one bug, and it comes down to where a separator is allowed to appear. How an MCP tool gets its name Claude Code gives every MCP tool one flat name: mcp__, then the server name, then __, then the tool name. The permissions docs show the three rule shapes that follow from it. mcp__puppeteer matches any tool from the puppeteer server, mcp__puppeteer__* matches the same set with a wildcard, and mcp__puppeteer__puppeteer_navigate matches one tool. The server name is whatever you typed into claude mcp add, or the key in a .mcp.json file. The MCP docs say those names "can contain only letters, numbers, hyphens, and underscores." Two underscores in a row are still underscores, so a__b and a_ are both legal server names. Tool names have the same freedom. The MCP spec lists letters, digits, underscore, hyphen and dot as the allowed characters, so a server is free to call a tool b__whoami. Now build the names for one tool, whoami, on three servers: server a -> mcp__a__whoami server a__b -> mcp__a__b__whoami server a_ -> mcp__a___whoami The separator is __, and the server name is allowed to contain __. Read the second line from the left and stop at the first __ after the prefix: you get server a, tool b__whoami. Do the same with the third line and you get server a, tool _whoami. Both now look like tools of server a, and a bare mcp__a rule means "any tool of server a." !Two luggage tags on one hook, a short one and a longer one with the same shape at the top, joined by a small orange chain Two different servers, one prefix: the tool name alone can't say where the server name ends. The changelog doesn't publish the old matcher, so treat the paragraph above as a reconstruction. It is the simplest reading I could find that produces both of the changelog's examples (a__b and a_). It also explains the deny-side fix in the same release. A deny rule mcp__a__b, written to block server a__b, reads as "server a, tool b" under the same split, and no tool of a__b is literally called b. The release notes say deny and ask rules "for an MCP server (mcp__<server>)" didn't apply when "the server's normalized name contains a double underscore." Same split, opposite direction. Server a with a tool named b__whoami, and server a__b with a tool named whoami, produce the identical string mcp__a__b__whoami. No parser can tell those two apart from the name alone. The matcher has to know which servers are configured, which is what "the server's whole normalized name" in the changelog points at. The MCP spec warns about this from the client side: tools aggregated from several servers "MAY encounter naming collisions," and clients "SHOULD implement a disambiguation strategy such as prefixing tool names with a server identifier." Prefixing helps only when the identifier can't contain the separator. Why this is a security change An allow rule skips the permission prompt. A rule that reaches a second server skips the prompt for a server you never reviewed. Who picks server names? You do, with claude mcp add. So does any repository you open that ships a .mcp.json. In an interactive session, Claude Code asks you before connecting those project servers, per the permissions docs. Under claude -p or the SDK in a folder you never trusted, the same docs say they're "Connected without asking, approved or not," which matters if your CI runs claude -p against checked-out repos. And a server connecting is a different decision from letting each of its tool calls run unprompted. Picture a user-level mcp__github rule and a project server named github__mirror: on the old matching, once that server connected, the rule plausibly covered its tools as well. That's hypothetical. I haven't seen a reported incident. Anthropic's managed MCP docs make the general point in a warning: a serverName entry "is not a security control," because "a user can call any server github." For admin allowlists they point you to serverCommand or serverUrl. Permission rules have no such option. They key on the name, so it pays to know how far a name reaches. !A single brass key fitting two doors side by side, the second door slightly ajar with burnt-orange light behind it An allow rule written for one door that also opened the one next to it. Reproduce it with two dummy servers You need Node and Claude Code. The server below uses the v2 MCP TypeScript SDK, whose package is @modelcontextprotocol/server, and exposes one tool that says which server answered: // dummy.mjs: one tool that names the server it runs as import { McpServer } from '@modelcontextprotocol/server'; import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; const label = process.argv[2] ?? 'dummy'; const server = new McpServer({ name: label, version: '1.0.0' }); server.registerTool( 'whoami', { description: 'Say which server answered', inputSchema: z.object({}), }, async () => ({ content: [{ type: 'text', text: `served by ${label}` }], }), ); await server.connect(new StdioServerTransport()); Register the same file twice, under two names, in a scratch folder: mkdir mcp-collide && cd mcp-collide npm init -y npm i @modelcontextprotocol/server zod # save dummy.mjs here, then: claude mcp add a -- node "$PWD/dummy.mjs" a claude mcp add a__b -- node "$PWD/dummy.mjs" a__b claude mcp list claude mcp add defaults to local scope, which only applies in this folder. Now allow server a and nothing else, in the folder's local settings: mkdir -p .claude cat > .claude/settings.local.json <<'EOF' { "permissions": { "allow": ["mcp__a"] } } EOF Run both calls in dontAsk mode. The permission modes docs define it as: pre-approved tools run, and "anything that would prompt is denied." That turns the allow rule into a clean yes or no, with no prompt to click through: claude -p "Call the whoami tool of the a MCP server" \ --permission-mode dontAsk claude -p "Call the whoami tool of the a__b MCP server" \ --permission-mode dontAsk The first call should run on any version. On 2.1.299 or later, the changelog says mcp__a no longer covers a__b, so dontAsk denies the second. Before 2.1.299, the rule reached a__b too, and the reply would say "served by a__b". I haven't run this myself, so check claude --version and compare your result with the changelog. A third server named a_ covers the other example, and claude mcp remove cleans up. One timing note. The setup docs say the stable release channel is "typically about one week old," and Homebrew's claude-code cask tracks it. 2.1.299 shipped on October 14, so if you're on stable in the week after that, there's a good chance you still have the old matching. Find rules that leaned on the old matching The rules live in your user settings (~/.claude/settings.json), the project's .claude/settings.json and your .claude/settings.local.json, all listed in the permissions docs. Server names live in ~/.claude.json (user and local scope) and in the project's .mcp.json, per the MCP docs. This script, run from a project root, pulls bare mcp__ rules from the settings files and compares each one with every configured server name. It needs jq: #!/bin/sh # Bare mcp__ rules that reached a second server before 2.1.299 servers=$(jq -r '.. | objects | .mcpServers? // empty | keys[]' ~/.claude.json .mcp.json 2>/dev/null | sort -u) rules=$(grep -hoE '"mcp__[A-Za-z0-9_-]+"' \ ~/.claude/settings.json .claude/settings.json \ .claude/settings.local.json 2>/dev/null \ | tr -d '"' | sort -u) for r in $rules; do s=${r#mcp__} for n in $servers; do case $n in "$s"__*|"$s"_) echo "$r also covered server $n" ;; esac if [ "$s" = "$n" ]; then case $n in *__*) echo "$r names $n: deny/ask missed it" ;; esac fi done done The first check flags an allow rule like mcp__a when a server named a__… or a_ exists: that rule now covers less than it used to. The second flags a rule that names a server containing __ exactly. On old versions, deny and ask rules of that shape didn't fire. The grep sees every mcp__ string in those files, allow, ask and deny alike, so read each hit against the list it sits in. Two more places hold MCP rules. Subagent files carry tools: lists, which 2.1.299 also narrowed, so run grep -rn 'mcp__' .claude/agents ~/.claude/agents. And CI scripts often pass rules on the command line with --allowedTools. Servers from plugins and claude.ai connectors aren't in those two files, so check claude mcp list for those names too. The fix: name the whole server The changelog spells out the form: mcp__<server>__*, with the server's whole normalized name. For the two dummy servers: { "permissions": { "allow": ["mcp__a__*", "mcp__a__b__*"] } } For a server bundled in a plugin, the whole normalized name includes the plugin. The MCP docs give the pattern mcp__plugin_<plugin-name>_<server-name>__<tool-name>, where any character outside letters, digits, _ and - becomes _. The MCP docs warn that a hook matcher written against the bare server key never fires for a plugin server. A permission rule matches the same tool names, so write it against the full plugin_… name. My own preference goes one step further: keep __ out of server names entirely. Hyphens are allowed, so a-b gives you mcp__a-b__whoami, which can't be split two ways. Remove the server, add it again under the new name, and update the rules that pointed at it. And per the permissions docs, rules are checked "deny, then ask, then allow," with the first match winning, so a broad deny still beats any allow you wrote. Where Hermes IDE fits I build Hermes IDE, so weigh this section with that in mind. Hermes runs each agent's own interface in a real terminal, so the permission rules, the prompts and this fix all belong to Claude Code. Hermes never answers a permission prompt for you. Two things in it help here. Press ⌘N (Ctrl+Shift+N on Windows and Linux) and the agent picker lists the installed agents with their versions, so you can see whether you're on 2.1.299. !The Hermes ⌘N New task sheet with its agent picker open, Claude Code 2.1.299 selected and Codex 0.145.0 installed The ⌘N New task sheet with its agent picker open: Claude Code (installed, 2.1.299) selected, Codex installed (0.145.0), the other supported agents marked Not installed, plus Custom agent. And Hermes saves every agent turn, so the Review Desk (⌘G) shows changes by turn or by file. If an agent edits .claude/settings.json or .mcp.json mid-task, that file shows up in the turn that changed it, and you can restore that one turn. Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It isn't affiliated with Anthropic, OpenAI, Nous Research or any agent vendor. Download it, and if it's useful, star it on GitHub. --- If this was useful This bug lived at a boundary: one flat string carrying two identifiers, with a separator that both of them were allowed to contain. If you like reasoning about where systems break at their seams, my System Design Pocket Guide: Fundamentals covers the core building blocks of scalable systems in a short read. It's one of 8 short books in the Pocket Guides for Developers series, on agents, RAG, prompts, observability, events, databases and system design. ![System Design Pocket Guide: Fundamentals — Core Building Blocks for Scalable Systems](amazon.com)

    1 reaction · 1 comment
  8. A failure that looked like a capability gap between coding agents was caused by how the API key was passed Summary An AI agent compared three coding agents in a research repository that is scheduled to be made public. The research rebuilds an integrated agent development environment with OSS. The model and the tasks were the same for all three, and there were eight tasks. The source material consists of the records in this repository. In this comparison, a parent agent calls a parallel-execution MCP server. The parallel-execution MCP server is a program built as part of the research, and when it is called, it launches child agents in parallel. Only in the three runs with OpenHands CLI as the parent did none of the 32 child agents launched in total complete. Both the status field in the run record files and the result field of the measurement stayed normal, and for about a day this outcome was treated as a result of the comparison. The cause was not a difference in capability between the coding agents but the way the measurement script was built. The measurement script left the delivery of the API key that the child agents use to two things: inheritance of the parent process's environment variables and loading of a .env file. Only when the parent was OpenHands CLI and the run took place in an isolated working directory did the API key fail to arrive by either route. The AI agent fixed the measurement script so that it writes the API key in the env field of the MCP server configuration and passes it that way. After the fix, the AI agent measured the condition with OpenHands CLI as the parent again, once each in two cases: with the set of instruction files in place and without it. The set of instruction files is a group of files, such as AGENTS.md, placed in the working directory. Under both conditions, the API key no longer caused every child agent to fail. With the set of instruction files in place, all eight child agents completed. Without it, 15 of the 16 child agents launched did not complete, for reasons other than the API key. The first section of the body gives the overall picture of the cause and the fix. The sections after it are arranged in the following order. - Three sections explain how the comparison results were skewed, in this order: the measurement conditions and results, why the failure did not show up in the results, and how the cause was isolated - Whether the parent process's environment variables reach the MCP server is checked for each implementation in the public source code - Three sections explain the fix, in this order: the fix to how the API key is passed and the re-measurement, the mechanism that does not hide failures and the unit tests, and the protection of the API key - Before the section on the materials referred to, two rules for designing comparison measurements are presented The body names the products of the three coding agents compared. The content does not represent the views of any of their providers. What you can take away If you launch other agents from a coding agent through an MCP server, you will be able to do the following three things. - Check in the source code which of the parent process's environment variables your coding agent passes to a stdio MCP server. The range it passes differs by implementation - Fix your setup so that the API key the child agents use is written in the env field of the MCP server configuration and passed that way - Add to your own measurements a step that does not count failures caused by defects on the measurement side as results of a coding agent's capability This article is a discussion based on the run records in the research repository scheduled to be made public and on the public source code checked at the time of writing. --- Two implicit routes that the API key delivery depended on This section first explains why every child agent failed in the comparison measurements and what the setup looks like after the fix. The comparison measurements are a series of runs that measured three coding agents in turn, with the same model and the same tasks, while changing the conditions. A coding agent is a CLI program that is started from a terminal and that edits files and runs commands while calling a model. In the comparison measurements, the parent agent calls the parallel-execution MCP server. The parent agent is the coding agent process that receives the tasks and is started first. The parallel-execution MCP server is an MCP server built as part of the research, and when it is called, it launches child agents in parallel. An MCP server is a program that provides tools for a coding agent to call through a common protocol called MCP. The MCP servers in this article are of the stdio type: the agent starts them as child processes and communicates with them over standard input and output. A child agent is a coding agent process launched by the parallel-execution MCP server. The measurement script is a Python script that passes the tasks to the parent agent and starts it, then judges the results from the actual files and records them. Before the fix, the child agents' API key arrived by one of the following two implicit routes. The API key is the credential for calling the inference service. The inference service is a service that makes models available to call through an API. - Environment variable inheritance: the environment variables held by a parent process are passed as they are to the child processes it starts - Loading the .env file: the route by which the parallel-execution MCP server reads a .env file. A .env file holds configuration values such as API keys as lines of the form "name=value", and it is kept out of git Only when the parent was OpenHands CLI and the run also took place in an isolated working directory did the API key fail to reach the child agents by either of the two routes. An isolated working directory is a separate working directory of the repository, created with git worktree. This summary combines three conditions found in the research records, and the research records contain no statement in this form. The three conditions are explained in the section "Isolating the cause by rerunning with the run records kept". After the fix, the measurement script writes the API key in the env field of the MCP server configuration and passes it explicitly. The env field of the MCP server configuration is a field in the coding agent's configuration file. This field lists, for each MCP server, the environment variables to pass to the process that is started. The field is named environment in OpenCode and env in the other two. When the API key is needed but missing, the parallel-execution MCP server stops before launching child agents and marks the run record file as failed. A run record file is a JSON file that the parallel-execution MCP server writes for each call. It has fields for the number of child agents launched, the number of child agents completed, and the status. The following diagram shows the routes before and after the fix. Before the fix The shell that started the measurement script (with the API key loaded) └ Parent agent └ Parallel-execution MCP server │ Route 1: environment variable inheritance │ Did not arrive when the parent was OpenHands CLI │ Route 2: read the .env file at the top level of the repository │ The isolated working directory has no .env file └ Child agent (the API key value is an empty string) After the fix Measurement script └ Writes the API key in the env field of the MCP server configuration └ Parallel-execution MCP server ├ API key present: launches child agents └ API key needed but missing: stops before launching and marks the run record file as failed Conditions of the comparison measurements and the number of completed child agents per parent agent This section shows the conditions of the comparison measurements and the results for each parent agent in a table. The conditions of the comparison measurements were as follows. - Versions: OpenHands CLI version 1.16.0, Qwen Code version 0.21.13, and OpenCode version 1.18.18 were used - Model: one model was fixed for all runs and called through the OpenAI-compatible endpoint of the inference service - Tasks: eight independent programming tasks. Each task has a task statement and a test script - Instructions to the parent agent: two, namely to finish all eight without leaving any out and not to rewrite the test files. The instructions did not mention the name of the tool - How the parallel-execution MCP server was provided: it was registered in each coding agent's configuration file. Where it was registered differs among the three. For Qwen Code, it was registered in a configuration file placed in the working directory. For OpenCode, it was also registered in a configuration file placed in the working directory. For OpenHands CLI, it was registered in mcp.json placed in a dedicated storage directory - Child agents: for every parent agent, they were started in Qwen Code's non-interactive mode The following table summarizes, for each parent agent, the runs in which the parallel-execution MCP server was called. | Parent agent | Runs in which a call occurred | Run record files | Child agents launched | Child agents completed | |---|---|---|---|---| | Qwen Code as parent | 3 runs | 1, 1, and 2 per run | 8 per run | 8, 8, and 7 per run | | OpenCode as parent | 1 run | 3 | 16 in total | 8 in total | | OpenHands CLI as parent | 3 runs | 4 in total | 32 in total | 0 in total | In the three runs with OpenHands CLI as the parent, 0 of the 32 child agents launched completed. In the run with OpenCode as the parent, the parent agent fixed an error in the orchestration script by itself and ran it again. The orchestration script is a script, run by the parallel-execution MCP server, that describes the steps for launching child agents. Even in the three runs with OpenHands CLI as the parent, all eight tasks passed, and there were 0 rewrites of the test files. The records can be read as showing that the parent agent solved by itself the work that the child agents did not complete. However, the research records contain no breakdown of the parent agent's work. A judgment design in which the failure of every child agent did not show up in the results This section explains how a result in which every child agent failed continued to be treated as a result of the comparison. The measurement script judged whether the parallel-execution MCP server had been called by whether a run record file existed. Only the parallel-execution MCP server process writes run record files, so none is created if there is no call. The measurement script recorded the numbers of child agents launched and completed by summing the fields of the run record files. For pass or fail on the tasks, it restored the test files, ran the tests for each task, and recorded the number of tasks that passed. Even though every child agent failed, the failure did not show up in the results, for the following three reasons. - If a run record file existed, the measurement script treated the run as having a call and set the result field to success. Even if not a single child agent completed, the result field became success - The run record files written by the parallel-execution MCP server also all had ok, which indicates normal, in the status field - All eight tasks had passed, so the failure was not visible from the task results either The result in which OpenHands CLI was the parent and 0 child agents completed was treated as a result of the comparison measurements for about a day. Isolating the cause by rerunning with the run records kept This section explains how the cause was isolated, in the order of the work. According to the research records, the AI agent did the implementation, ran the measurements, and handled the PRs. This AI agent works separately from the reviewing AI agent that examines the diffs of PRs. The AI agent reran the condition with OpenHands CLI as the parent just once. This time, it kept the directory that holds the run record files instead of deleting it after the run. In the records it retrieved, all eight child agents had exited about 1.2 seconds after starting. The reason for exiting was an error saying that no authentication method had been selected. The text of this error is in the file in Qwen Code version 0.21.13 that checks authentication in non-interactive mode. When Qwen Code starts in non-interactive mode, it determines the authentication method from the command arguments, the environment variables, and the configuration files. If none of them determines it, Qwen Code stops with this error. The cause was the combination of the following three conditions. 1. When the parent was OpenHands CLI, the parent process's environment variables did not reach the parallel-execution MCP server process. When the parent was Qwen Code or OpenCode, they did reach it, so the API key loaded into the shell made it all the way to the child agents 2. When the API key was not in the environment variables, the parallel-execution MCP server was built to read the .env file in the top-level directory of its own repository. The comparison measurements ran in an isolated working directory, and that directory had no .env file 3. The measurement script did not write the API key in the env field of the MCP server configuration When launching child agents, the parallel-execution MCP server put an empty string into the environment variable if it could not get the API key value. Before that, there had also been a separate measurement with OpenHands CLI as the parent. In that measurement, the parent agent was explicitly made to call the parallel-execution MCP server, and one child agent had completed. The research records say that the explanation that fits best is that this run took place in the main working directory and the .env file was read. There is no record of which working directory it ran in, so this explanation has not been confirmed. Differences between implementations in whether the parent process's environment variables reach the MCP server This section shows the results of checking, for each implementation in the public source code, whether the parent process's environment variables reach a stdio MCP server. The checks were made on 2026-09-29. The following table summarizes the environment variables that each implementation passes to the MCP server process when it starts a stdio MCP server. | Implementation | Environment variables passed to the MCP server process | Handling of the env field in the configuration | Source | |---|---|---|---| | The official MCP Python SDK | By default, only a list of environment variables that are safe to inherit. On POSIX, these are six: HOME, LOGNAME, PATH, SHELL, TERM, and USER | Passed layered on top of the six | Source code of the stdio client | | The official MCP TypeScript SDK | On POSIX, the same six as the Python SDK | Passed layered on top of the list | Source code of the stdio client | | Qwen Code version 0.21.13 | All of the parent process's environment variables, excluding only three environment variables used to authenticate this product's daemon and its child processes | Passed layered on top of all of them | Source code of the MCP client | | OpenCode version 1.18.18 | The whole of the parent process's environment variables | The environment field is passed layered on top of the whole | Source code of the MCP handling | | OpenHands CLI version 1.16.0 | Reading the dependency path, the six defaults of the official MCP Python SDK | The env field of the configuration is passed to the startup arguments of the official MCP Python SDK | Dependency definition, MCP handling in openhands-sdk, stdio handling in fastmcp | Notes on each row are as follows. - The same default of the official MCP Python SDK is also in the source code of the stdio client in the 1.x series. The latest release is v2.2.0, published on 2026-09-07 - Qwen Code has a file with a function that removes values that are not to be passed (sanitize-child-env.ts). The comments in this file give the reason for limiting what is excluded to internal values. The reason is that commands users run in the shell depend on inheriting third-party credentials - Qwen Code 0.24.6 was published on 2026-09-26. On the main branch after that as well, the Qwen Code CLI is still built to inherit the whole of the parent process's environment variables - On OpenCode's dev branch as well, the design is the same. The latest release is v1.18.33, published on 2026-09-28 - OpenHands CLI version 1.16.0 pins openhands-sdk to 1.21.0 in its dependency definition. The MCP handling in openhands-sdk passes the configuration to fastmcp's MCP configuration type. The current source code of fastmcp passes the env field of a stdio MCP server's configuration as it is to the startup arguments. The startup arguments are passed to the official MCP Python SDK. The latest release of OpenHands CLI is 1.16.0, published on 2026-05-08, the same version used in the measurements The OpenHands CLI row is an explanation obtained by reading the source code of the MCP part. This explanation fits best with the observation that the environment variables did not arrive when the parent was OpenHands CLI. However, the exact versions of fastmcp and the official MCP Python SDK installed at the time of the measurements have not been checked. So this explanation is not the result of checking the whole route in the measurement environment. The observed fact is limited to the way these measurements were started. When the parent was OpenHands CLI version 1.16.0, the parent process's environment variables did not reach the MCP server. The official documentation of the three coding agents describes the field for passing environment variables. The pages checked are the following three. - Official documentation on MCP servers for OpenHands CLI - Official documentation on MCP servers for Qwen Code - Official documentation on MCP servers for OpenCode On the other hand, none of the three pages says whether a started MCP server receives the whole of the parent's environment. The inheritance behavior is determined only by the implementation in the source code, and it can change between versions. Both designs have reasons behind them. The default in the official SDKs narrows the inherited environment variables to avoid leaking secret values. Qwen Code limits what it excludes to values for the product's internal use. The comments in the source code give the reason that commands users run in the shell depend on inheriting third-party credentials. Qwen Code version 0.21.13 passes the parent's environment variables only through that same function when it starts an MCP server as well. The measurement script after the fix does not depend on either design. This is because the measurement script writes the API key in the env field of the MCP server configuration and passes it explicitly. The fix that passes the API key explicitly in the env field of the MCP server configuration, and the re-measurement This section shows the fix to how the API key is passed and the results of the re-measurement after the fix. The AI agent fixed the measurement script. After the fix, the measurement script writes the API key that the child agents use in the env field of the MCP server configuration and passes it explicitly. The AI agent also fixed, in the same way, a separate measurement script that explicitly makes the parent agent call the parallel-execution MCP server. That values written in the env field reach the MCP server in all three coding agents had already been confirmed earlier, by passing a different value. The AI agent set up a unit test to confirm that the API key is written in OpenHands CLI's mcp.json. After that, it extended the unit test to all three coding agents. The following examples show the form for writing the API key in the env field of the MCP server configuration, written from scratch to match the form in the official documentation. The server name, the environment variable name, and the value are placeholders. The first is the Qwen Code form, and OpenHands CLI's mcp.json uses the same form. The second is the OpenCode form. { "mcpServers": { "parallel-runner": { "command": "python", "args": ["-m", "example_parallel_runner"], "env": { "EXAMPLE_API_KEY": "replace-with-your-api-key" } } } } { "$schema": "https://opencode.ai/config.json", "mcp": { "parallel-runner": { "type": "local", "command": ["python", "-m", "example_parallel_runner"], "environment": { "EXAMPLE_API_KEY": "replace-with-your-api-key" } } } } After the fix, the AI agent measured two conditions with OpenHands CLI as the parent again, once each. The two conditions differ in whether the set of instruction files for agents is in place. The set of instruction files for agents is the group of AGENTS.md, skill files, and memory files placed in the working directory. The following table shows the results of the re-measurement under the two conditions. | Condition | Child agents launched | Child agents completed | Tasks passed | Time taken | |---|---|---|---|---| | Set of instruction files in place | 8 | 8 | 8 of 8 | 556.1 seconds | | Set of instruction files not in place | 16 in total | 1 in total | 8 of 8 | 761.1 seconds | The time limit was 900 seconds. Under the condition with the set of instruction files in place, the parent agent called the parallel-execution MCP server on its own. All eight child agents completed. This was the first run, under a condition with OpenHands CLI as the parent, in which the parent agent called the parallel-execution MCP server on its own and every child agent launched completed. Under the condition without the set of instruction files, a call also occurred, and there were three run record files. Of the 16 child agents launched under this condition, 15 did not complete, for reasons other than the API key. The research records say that these 15 include the pattern of fixing an error in the orchestration script and running it again. The research records also say that observation of the causes of these 15 will continue. From these results, the AI agent concluded as follows. The cause of 0 completed child agents was not a difference in capability between the coding agents but the way the measurement script was built. In response to feedback from the reviewing AI agent, the AI agent rewrote the scope of the conclusion. The reviewing AI agent is an AI agent that is started after a PR is created, separately from the agent that did the implementation. It examines the diff read-only. In the rewritten conclusion, the AI agent stated definitively that the problem of every child agent failing because of the API key had been resolved. It separated out the remaining 15 incomplete child agents as having reasons other than the API key. The two re-measurement runs were recorded separately from the comparison totals, as additional measurements for isolating the cause. The re-measurement was one run for each condition. So these records cannot tell how often every child agent launched completes when the parent is OpenHands CLI. A mechanism that does not hide failures, and unit tests that prevent the same omission This section explains the mechanism that does not hide failures and the unit tests that prevent the same omission, both added in PRs after the API key fix. The AI agent added handling for when the API key is needed but missing. In this case, the parallel-execution MCP server stops before launching child agents and marks the run record file as failed. It does not treat the stop as the failure of one child agent, and marks the whole call as failed. The PR that added this handling went through three reviews. 1. In the first review, the reviewing AI agent demonstrated a problem by actually running the code. The problem was that the exception from the stopping logic was treated as the failure of one child agent, and the status in the run record file was left as normal 2. In the second review, the reviewing AI agent showed that with the parallel and sequential ways of writing the orchestration, the same exception was still treated as normal 3. In response to the third review, the AI agent changed the stopping logic so that, once it runs, it leaves a failure flag in the run record file. This flag remains even if the orchestration script catches the exception and swallows it In the same PR, the AI agent removed the logic that, when the API key was empty, overwrote the caller's environment variable with an empty string. After the fix, when there is no API key, the parallel-execution MCP server passes the authentication in the caller's environment to the child agents as it is. In the same PR, the AI agent also added logic that automatically puts a flag on the rows the measurement script records, indicating that there was a defect on the measurement side. Rows with this flag are not counted in the totals as observations of an agent's capability. The flag is added when a task run meets all three of the following conditions. - The exit code is not 0 - The tests were run 0 times - The total time from start to scoring is less than 15 seconds The conditions were made narrow because, if failures caused by a lack of capability were excluded from the totals as defects on the measurement side, the totals would look better than the actual capability. The logic that adds this flag is separate from the logic that judges whether the parallel-execution MCP server was called. The same kind of omission also happened with another environment variable. An isolated directory is passed through an environment variable to Qwen Code, the child agent. This directory is used in place of the user's home directory. The measurement script set this value at the point of starting the parent only when the parent was Qwen Code. So this value reached the child agents through environment variable inheritance only when the parent was Qwen Code. When the parent was one of the other two, the value did not exist at the point of starting the parent, so it did not reach the child agents. The AI agent changed this value as well so that it is written in the env field of the MCP server configuration and passed explicitly for all three coding agents. It confirmed this setup with a unit test. The reviewing AI agent pointed out two places with the same omission. The two places were a separate measurement script and an integration test. The AI agent fixed both places in the same way. In a later PR, the AI agent consolidated the logic that builds the env field of the MCP server configuration into a single function. This is because, if callers set values separately, the same omission happens when the number of places to register grows. The unit test confirms that the set of environment variable names in the env field exactly matches a defined set. If a new credential is written into a configuration file without anyone noticing, this unit test fails. Protection added because the API key is now written in the env field This section explains the new risk created by the fix that passes the API key explicitly, and the protection added in the same PR. After the fix, the API key written in the env field is written as a raw value into a configuration file inside the working directory. The reviewing AI agent returned seven findings on this PR. Each finding is given one of four severity levels: Critical, High, Medium, or Low. All seven had Medium severity. In response to the findings among the seven that concerned protecting the API key, the AI agent fixed the following two things. - It closed off the routes by which the API key value could get into the measurement script's records and into commits. It added logic that mechanically masks values that look like API keys before the records are written, and confirmed this with a unit test. It also fixed the check that runs before a commit and looks for API key values mixed in, staged a file containing an API key value, and confirmed that the commit was rejected - It changed the setup so that the API key is written only in the configuration of the parallel-execution MCP server, which launches the child agents. The key is not written in the configuration of other MCP servers that do not use it. This setup was also confirmed with a unit test Two rules for designing comparison measurements This section presents two rules for designing comparison measurements that can be generalized from these records. The research records contain no statement in this form. 1. Before starting comparison measurements, identify the implicit behaviors that differ between coding agents. Whether the parent process's environment variables reach the MCP server is one of those behaviors 2. Do not leave credentials to environment variable inheritance, and pass them explicitly from the measurement script The basis for rule 1 is the observation that only when the parent was OpenHands CLI did the environment variables fail to arrive, and the source code in which the inherited range differs by implementation. The basis for rule 2 is the facts written in the section on the fix that passes the key explicitly in the env field and the re-measurement, and in the section on the mechanism that does not hide failures. As a supplementary note, the same kind of failure also happened on an external evaluation execution platform. The AI agent ran Qwen Code over ACP on the external evaluation execution platform. ACP is a public protocol for connecting editors and coding agents. This run got as far as starting Qwen Code and connecting over ACP, and then failed with an error at the authentication stage. The task score was 0. In the logic that started the execution platform at that time, the AI agent could not find a configuration item for passing the model's authentication information to the container. The research records treat this as the cause. The measurement script in the same repository had gotten through the same authentication by passing the environment variables and a copy of the configuration file. The AI agent recorded this run as a defect on the measurement side, not as an observation of capability. The inheritance behavior of the parent process's environment variables can be checked in the source code of the version in use, not in the official documentation. The part to read is the logic that starts a stdio MCP server. It is a good idea to check whether that logic passes the whole of the parent's environment or narrows it with a list, and only then use that version of the coding agent in comparison measurements. Materials I referred to The public materials referred to in the body are as follows. All of them were checked on 2026-09-29. - The source code of the stdio client in the official MCP Python SDK github.com/modelcontextprotocol/python-sdk/blob/m… - The source code of the stdio client in the 1.x series of the official MCP Python SDK github.com/modelcontextprotocol/python-sdk/blob/v… - The release page of v2.2.0 of the official MCP Python SDK github.com/modelcontextprotocol/python-sdk/releas… - The source code of the stdio client in the official MCP TypeScript SDK github.com/modelcontextprotocol/typescript-sdk/bl… - The source code of the MCP client in Qwen Code version 0.21.13 github.com/QwenLM/qwen-code/blob/v0.21.13/package… - The source code of the function that removes values not to be passed, in Qwen Code version 0.21.13 github.com/QwenLM/qwen-code/blob/v0.21.13/package… - The source code that checks authentication in non-interactive mode, in Qwen Code version 0.21.13 github.com/QwenLM/qwen-code/blob/v0.21.13/package… - The release page of Qwen Code 0.24.6 github.com/QwenLM/qwen-code/releases/tag/v0.24.6 - The source code of the MCP handling in OpenCode version 1.18.18 github.com/anomalyco/opencode/blob/v1.18.18/packa… - The release page of OpenCode v1.18.33 github.com/anomalyco/opencode/releases/tag/v1.18.… - The dependency definition of OpenHands CLI version 1.16.0 github.com/OpenHands/OpenHands-CLI/blob/1.16.0/py… - The release page of OpenHands CLI 1.16.0 github.com/OpenHands/OpenHands-CLI/releases/tag/1… - The source code of the MCP handling in openhands-sdk version 1.21.0 github.com/OpenHands/software-agent-sdk/blob/v1.2… - The source code of the stdio handling in fastmcp github.com/PrefectHQ/fastmcp/blob/main/fastmcp_sl… - The official documentation on MCP servers for OpenHands CLI docs.openhands.dev - The official documentation on MCP servers for Qwen Code qwenlm.github.io - The official documentation on MCP servers for OpenCode opencode.ai --- Originally published at The Future of Humans, AI, and the Web, a site where my research and development is recorded and analyzed by a human and an AI.

    2 reactions · 6 comments
  9. DEV · · @sh20raj

    MCP vs API: What's the Difference? A Visual Guide for Developers MCP vs API: What's the Difference? A Visual Guide for Developers Imagine building an AI assistant that can search GitHub issues, query databases, read documentation, create tasks, and send Slack messages. All these services already have APIs. So, why do we need MCP? Is MCP replacing APIs? Is it just another type of API? And what actually happens when an AI agent calls a tool? Let's break it down, from the fundamentals to production architecture. 1. First, what is an API? An API (Application Programming Interface) defines how one piece of software can interact with another. For example, imagine a task management application that exposes this endpoint: GET /api/tasks/42 Authorization: Bearer YOUR_TOKEN It returns: { "id": 42, "title": "Fix login bug", "status": "in_progress", "assignee": "Sh" } Your application knows which endpoint to call, what authentication to provide, and how to interpret the response. APIs aren't limited to REST or HTTP. They can also be library interfaces, GraphQL APIs, RPC interfaces, and more. How does a typical API request work? flowchart TD A[Frontend or Backend Client] -->|HTTP Request| B[API Server] B --> C[Authentication and Validation] C --> D[Business Logic] D --> E[(Database)] E --> D D --> B B -->|HTTP Response| A The client constructs the request. The server validates it, executes the operation, and returns a result. Simple enough! But now imagine letting an AI model use hundreds of these APIs. That's where things get interesting. 2. The problem: APIs weren't designed specifically for AI tool interoperability Suppose you're building an AI coding assistant. You want it to: - Read files from GitHub. - Search GitHub issues. - Query a PostgreSQL database. - Create tasks in Linear. - Read external documentation. - Send messages to Slack. Each service may already provide an API. However, your AI application still needs to understand: - Which operations are available. - What arguments each operation expects. - How to invoke an operation. - How to handle its output and errors. - How to manage credentials and permissions. - How to expose the capability to the AI model. Traditionally, developers build custom integration code for these tasks. flowchart TD A[AI Application] --> B[GitHub Adapter] A --> C[Database Adapter] A --> D[Slack Adapter] A --> E[Linear Adapter] B --> F[GitHub API] C --> G[(PostgreSQL)] D --> H[Slack API] E --> I[Linear API] This works, but each integration can require its own implementation and maintenance. Now imagine building another AI application that needs access to the same services. You may end up repeating much of the integration work. The problem isn't that APIs don't work with AI. The problem is that every AI application shouldn't have to reinvent the integration contract for every tool. 3. What is MCP? MCP stands for Model Context Protocol. It's an open protocol that standardizes how AI applications connect to external systems that provide tools, data, and reusable prompts. Think of MCP as a common language for AI applications and the services that expose capabilities to them. Instead of creating a completely different integration mechanism for every AI client, developers can expose capabilities through an MCP server. Compatible clients can then discover and invoke those capabilities through a shared protocol. The MCP architecture flowchart TD A[User] --> B[AI Host] B --> C[AI Model] B --> D[MCP Client] D <-->|MCP Protocol| E[MCP Server] E --> F[Tools] E --> G[Resources] E --> H[Prompts] F --> I[APIs and Business Logic] G --> J[Files and Data Sources] H --> K[Reusable Templates] There are three important components: 1. Host: The AI-enabled application coordinating the model and integrations. 2. Client: The component that communicates with an MCP server. 3. Server: The program that exposes capabilities through MCP. An MCP server doesn't necessarily run on a remote machine. It can be a local process communicating through standard input/output (stdio), or a remote service using Streamable HTTP. MCP's three core primitives | Primitive | Purpose | Example | |---|---|---| | Tools | Execute operations | create_issue | | Resources | Provide contextual data | Database schema | | Prompts | Provide reusable templates | Code review instructions | Tools can read data or modify state. Resources provide information that an application can use as context. Prompts define reusable interaction templates. Not every MCP server needs to implement all three. 4. MCP vs API: the fundamental difference Here's the simplest way to understand it: > An API defines how to interact with a service. MCP standardizes how AI applications discover and interact with capabilities exposed by a server. These concepts are related, but they're not interchangeable. Side-by-side comparison | Feature | API | MCP | |---|---|---| | Primary purpose | Expose functionality or data | Standardize AI-to-tool/context integration | | Interface | Endpoints, functions, queries, RPC methods, etc. | Protocol messages and capability schemas | | Discovery | Documentation, SDKs, schemas, or custom mechanisms | Protocol-defined capability discovery | | AI-specific design | Not inherently | Designed for AI application integrations | | Actions | Depends on the API | Tools expose callable operations | | Context | Depends on the API | Resources can provide contextual information | | Reusable prompts | Not a defining feature | A defined primitive | | Transport | Depends on the API | Commonly stdio or Streamable HTTP | | Can coexist with the other? | Yes | Yes | MCP uses JSON-RPC 2.0 for protocol messages. It can use HTTP for remote communication, but it isn't simply another name for a REST API. An analogy Imagine a restaurant. API = the restaurant's ordering interface. The menu describes what you can order, the available options, and how to submit an order. MCP = a standardized ordering interface for compatible AI applications. Different AI applications can discover available operations and invoke them through a common protocol. The analogy isn't perfect, but it captures the central idea: standardizing how you interact with a service doesn't eliminate the service itself. 5. A real example: building a GitHub issue assistant Let's say you ask an AI assistant: "Find the open authentication bugs in my repository and summarize the most urgent ones." Your assistant needs to search GitHub issues and retrieve their details. Option A: Direct GitHub API integration A developer could call GitHub's REST API: GET /repos/OWNER/REPO/issues?state=open&labels=bug Authorization: Bearer GITHUB_TOKEN Accept: application/vnd.github+json The application must then: 1. Authenticate with GitHub. 2. Make the HTTP request. 3. Handle pagination and rate limits. 4. Parse the response. 5. Define a tool the AI model can invoke. 6. Execute that tool when requested. 7. Return the results to the model. A simplified model-facing tool definition might look like this: const tools = [ { type: "function", name: "search_github_issues", description: "Search open issues in a repository", parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" }, label: { type: "string" } }, required: ["owner", "repo", "label"], additionalProperties: false }, strict: true } ]; This describes the tool the model can request. Your application still needs to implement the function that calls GitHub. The API defines how to access GitHub. The tool definition tells the model how to request that access. Option B: Expose the capability through MCP An MCP server could expose a tool called search_issues. A compatible client can discover the tool and its input schema, then request its execution. An illustrative schema might look like this: { "name": "search_issues", "description": "Search issues in a repository", "inputSchema": { "type": "object", "properties": { "owner": { "type": "string" }, "repo": { "type": "string" }, "state": { "type": "string", "enum": ["open", "closed", "all"] } }, "required": ["owner", "repo", "state"] } } This is a simplified example of a tool definition, not a complete MCP protocol exchange. The MCP server might internally call GitHub's REST API. The client doesn't need to know the underlying endpoint URL or HTTP headers if the server handles those details. The complete flow sequenceDiagram participant U as User participant A as AI Host participant M as MCP Client participant S as MCP Server participant G as GitHub API U->>A: Find authentication bugs A->>M: Discover available tools M->>S: tools/list S-->>M: Tool definitions A->>M: Call search_issues M->>S: tools/call S->>G: HTTP API request G-->>S: Issue data S-->>M: Tool result M-->>A: Return results A-->>U: Summarize urgent bugs The host coordinates the model and tool execution. The MCP client handles protocol communication. The MCP server executes the operation, potentially using an existing API. MCP doesn't replace GitHub's API. It provides a standardized AI-facing interface to the capability. 6. Build a simple MCP server with TypeScript Let's make the example more concrete. Imagine you already have a backend endpoint: GET /api/tasks/:id You want compatible AI applications to retrieve tasks through MCP without rewriting the backend. Step 1: Install the SDK npm install @modelcontextprotocol/sdk zod Step 2: Create the server The following example demonstrates the MCP server pattern using TypeScript: import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; const server = new McpServer({ name: "task-server", version: "1.0.0" }); server.registerTool( "get_task", { description: "Get a task by its numeric ID", inputSchema: { id: z.number().int().positive() } }, async ({ id }) => { const response = await fetch( `https://your-api.example.com/api/tasks/${id}` ); if (!response.ok) { throw new Error(`Task API returned ${response.status}`); } const task = await response.json(); return { content: [ { type: "text", text: JSON.stringify(task) } ] }; } ); const transport = new StdioServerTransport(); await server.connect(transport); This example assumes an SDK version that supports the shown registration pattern. Check the official TypeScript SDK documentation for the exact version-specific API. Replace the placeholder URL with your actual backend and implement appropriate authentication, timeouts, authorization, and error handling. What happens when the AI calls get_task? 1. The MCP client discovers get_task. 2. The model decides that the tool is relevant. 3. The host orchestrates the tool call. 4. The client sends the request through MCP. 5. The server validates the input and calls the existing API. 6. The server returns the task information. 7. The model uses the result to answer the user. The MCP server acts as an adapter between the protocol and your existing service. It doesn't eliminate your backend's business logic or security requirements. 7. When should you use MCP instead of a direct API integration? Neither approach is universally better. Use a direct API integration when: - You have one application you control. - You need a small number of fixed operations. - You want explicit control over requests and orchestration. - A separate protocol adapter would add unnecessary complexity. Consider MCP when: - Multiple compatible AI clients need the same capabilities. - You want standardized tool discovery. - You want to expose reusable contextual resources. - You want to separate AI-facing integrations from backend implementations. - You are building an ecosystem of tools for AI agents. The best production architecture may use both flowchart TD A[Existing Backend] --> B[REST API] A --> C[Internal Service Layer] B --> D[Web and Mobile Apps] C --> E[MCP Server] E --> F[AI Client 1] E --> G[AI Client 2] E --> H[AI Client 3] Your web and mobile applications can continue using the existing API, while compatible AI clients use MCP to access selected capabilities. You don't need to rebuild your entire backend to support MCP. 8. Advanced considerations 8.1 Integration complexity Suppose you have N AI applications and M external services. With fully custom integrations, the potential number of application-to-service adapters can approach: N × M With compatible clients and servers using a shared protocol, the integration structure can often move toward: N + M This is a simplified architectural model, not a guaranteed reduction in engineering effort. Custom authentication, unsupported features, configuration, and service-specific logic can still require substantial work. The advantage becomes more significant as the number of integrations grows. 8.2 Latency and performance MCP does not automatically make requests faster. A direct integration might look like: Application → API → Application An MCP integration might look like: AI host → MCP client → MCP server → API The second approach may add serialization, protocol handling, or network overhead. However, an MCP server can also expose higher-level operations that reduce unnecessary round trips. For example, a single summarize_project_health tool might internally retrieve issues, pull requests, and project metadata, then return a consolidated result. Any performance improvement comes from the implementation and tool design, not from MCP alone. 8.3 Security is still your responsibility MCP is not a security guarantee. An MCP server may expose powerful capabilities such as reading files, querying databases, or modifying business records. Production systems should implement: - Least-privilege credentials. - Server-side authorization. - Strict input validation. - Explicit confirmation for sensitive or destructive actions when appropriate. - Protection against prompt injection and untrusted tool output. - Secure secret management. - Audit logging and appropriate monitoring. - Duplicate-action safeguards for operations that need idempotency. A tool description saying "read-only" is not a substitute for enforcing read-only access in the server. The system must enforce permissions independently of what the model requests. 9. Common misconceptions Myth 1: MCP replaces APIs. No. MCP servers frequently call existing APIs. Both can coexist. Myth 2: MCP is just another REST API. No. MCP uses JSON-RPC 2.0 messages and defines an AI-oriented client-server protocol. Remote MCP can use HTTP as its transport. Myth 3: Every API needs an MCP server. No. Direct API calls remain perfectly reasonable for simple, fixed integrations. Myth 4: MCP makes AI models smarter. No. MCP provides standardized access to capabilities and context. The model, orchestration logic, available information, and tool design still determine the quality of its work. Myth 5: MCP automatically makes applications secure. No. Authentication, authorization, isolation, and approval workflows remain essential. Myth 6: MCP servers must be remote. No. MCP supports local stdio communication as well as remote Streamable HTTP. 10. Frequently asked questions Is MCP an API? MCP is a protocol that defines how compatible applications communicate with servers. It can expose capabilities backed by APIs, but the two are different interfaces. Can I use MCP with a REST API? Yes. An MCP server can translate tool calls into REST requests without requiring the underlying REST API to implement MCP. Can I use APIs without MCP? Absolutely. Traditional applications and AI applications can call APIs directly. Is MCP the same as function calling? No. Function calling lets a model request that an application execute a function. MCP standardizes communication with servers that expose tools and other capabilities. An MCP client can connect these capabilities to a model's tool-calling mechanism. Does MCP work with any AI model? MCP is model-independent at the protocol level. However, the host application must implement MCP support and integrate the discovered capabilities with its model. Is MCP useful for agentic AI? Yes. MCP can provide tools and contextual information for agents. Planning, reasoning, execution loops, and decision-making are separate concerns. 11. Further reading If you want to go deeper, start with these resources: - Model Context Protocol — Official Documentation - MCP TypeScript SDK - OpenAI — MCP Servers - OpenAI Docs MCP These resources cover protocol concepts, implementation details, and practical integrations. 12. The final mental model Remember these three layers: Layer 1 — Reasoning: The AI model interprets the request and selects a capability. Layer 2 — Integration: MCP or application-specific tool integration makes the capability discoverable and callable. Layer 3 — Execution: APIs, databases, and business logic perform the actual work. For example, the AI model decides to create a GitHub issue. The integration layer exposes create_issue. The underlying service checks permissions and creates the issue. That separation is the core idea. > An API exposes functionality. MCP standardizes how AI applications can discover and access functionality. They are complementary technologies, and many strong architectures use both. --- Written for developers exploring AI integrations, MCP servers, and modern software architecture. What do you think? Are you already using MCP in your projects, or are you still integrating AI tools directly through APIs? Share your experience in the comments!

    5 reactions
  10. Zero of 78 public MCP servers actually validate the protocol era they claim Last week I published a probe for a quiet MCP failure: a server that compiles, passes its tests and answers the Inspector while silently downgrading newer protocol clients (typescript-sdk#2977). To see how common this is in the wild, we ran the probe against 78 public MCP endpoints — the same cohort as our earlier security-posture survey. Every endpoint got four real initialize handshakes, one per protocol era: 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26 — plus a tools/list to check the session actually works. The result surprised us, and then a second test surprised us more. The headline: 0 of 78 No endpoint in the cohort genuinely validates and serves the newest protocol era with a working session. The distribution: 43 of 78 (55%) top out at 2025-11-25 — the era the published SDK ships as its default. 19 more stop at 2025-06-18, 4 at 2025-03-26. 66 of 78 (85%) will silently downgrade a client that asks for 2026-07-28 — an older era comes back with no signal. That's the #2977 failure class, and it's not an edge case; it's two thirds of the public surface we measured. The twist: the "current" servers aren't current either Six endpoints answered the 2026-07-28 handshake verbatim. At first glance, that's 8% of the wave having arrived. Before publishing, we added one control: we also asked for eras that don't exist — 1999-01-01 and 2099-01-01. All six echoed those back verbatim too. They don't read the protocolVersion field at all — they copy whatever the client claims into the response. A client that says "I speak 2099" gets told "great, me too." The session keeps working (tools/list succeeds), so nothing breaks — but the negotiated version is fiction. If your client branches on features-by-era, these servers will lie to it. One more server does read the field: it accepted the 2026-07-28 handshake on October 6, and today it accepts the handshake and then fails the first session request with HTTP 400. Deployed, then broken. The migration wave moves in both directions. The purest failure shape Four endpoints serve nothing verbatim: whatever era you request, you get the same fixed default back (two of them default to 2024-11-05, four revisions behind the current one). That's the cleanest #2977 case there is — and none of them would flag it in a test suite that only checks "does initialize succeed." Run it yourself — don't trust our snapshot A snapshot of 78 servers goes stale in weeks, so the useful artifact isn't the table, it's the probe: node era-probe.js --http https://your-server.example/mcp It prints a per-era verdict (ACCEPTED / DOWNGRADE / REJECTED), what the server actually serves, whether tools/list works, and exits non-zero with --strict if the newest era isn't served — a CI gate for "we say we support the new era." MIT, zero dependencies, stdio and streamable-HTTP, at m0nk111-qwen-agent/mcp-era-probe. Honest limits - The cohort is 78 public endpoints that answered an anonymous handshake on Oct 6 — not a random sample of the whole ecosystem, and gated servers aren't represented. - Protocol era is not security. A server that downgrades is behaving per spec; the failure is a client-side interop hazard, not a vulnerability. - Our sweep ran 10 endpoints in parallel, which caused some transient handshake failures; affected endpoints were re-checked one at a time and the numbers above reflect the re-checks. - The blind-echo servers might serve the new era eventually without changing a line — the point is that a verbatim echo proves nothing. Ask for an era that doesn't exist; if the server "supports" it, the negotiation is decorative. --- Built by Pennyforge, a one-person studio. The census data (per-endpoint, dated, with methodology) lives in the repo alongside the probe.

    1 reaction · 1 comment

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

No packages found.

Live mentions badge

Show how many posts mention your project in the last 30 days.

Live mentions · last 30 days
modelcontextprotocol/typescript-sdk mentions in the last 30 days