Skip to content

Commit f3bb5dc

Browse files
Merge pull request #1443 from corbitsdev/cl-10653-mcp-sdk-130-132x-redirect-issuer-correct
fix(mcp): bump @modelcontextprotocol/sdk to 1.32.x
2 parents 6a28715 + f1bb9ff commit f3bb5dc

9 files changed

Lines changed: 191 additions & 11 deletions

‎bun.lock‎

Lines changed: 2 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@
105105
"@intx/tools-posix": "workspace:*",
106106
"@intx/types": "workspace:*",
107107
"@intx/workflow-host": "0.3.0",
108-
"@modelcontextprotocol/sdk": "^1.29.0",
108+
"@modelcontextprotocol/sdk": "^1.32.1",
109109
"@opentui/core": "0.6.4",
110110
"arktype": "catalog:",
111111
"isomorphic-git": "catalog:",

‎src/mcp/auth-store.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,52 @@ describe("mcp auth-store", () => {
208208
);
209209
});
210210

211+
test("persists the SDK's nested issuer stamps unchanged", async () => {
212+
const home = await tempHome();
213+
await saveAuthState(
214+
linear,
215+
{
216+
clientInformation: {
217+
client_id: "c1",
218+
redirect_uris: ["http://127.0.0.1:1/callback"],
219+
issuer: "https://as.example",
220+
},
221+
tokens: {
222+
access_token: "tok",
223+
token_type: "bearer",
224+
issuer: "https://as.example",
225+
},
226+
},
227+
home,
228+
);
229+
const final = await loadAuthState(linear, home);
230+
expect(final.tokens?.access_token).toBe("tok");
231+
expect(final.tokens?.issuer).toBe("https://as.example");
232+
expect(final.clientInformation?.client_id).toBe("c1");
233+
expect(final.clientInformation?.issuer).toBe("https://as.example");
234+
});
235+
236+
test("loads legacy credentials saved before issuer stamps", async () => {
237+
const home = await tempHome();
238+
// Pre-1.31 shape: no binding yet; the SDK consumes these as-is and
239+
// re-stamps on first successful use, so loading must not require the key
240+
// or invent one.
241+
await saveAuthState(
242+
linear,
243+
{
244+
tokens: {
245+
access_token: "tok",
246+
token_type: "bearer",
247+
refresh_token: "r",
248+
},
249+
},
250+
home,
251+
);
252+
const final = await loadAuthState(linear, home);
253+
expect(final.tokens?.access_token).toBe("tok");
254+
expect(final.tokens?.issuer).toBeUndefined();
255+
});
256+
211257
test("ignores legacy name-only auth state without modifying it", async () => {
212258
const home = await tempHome();
213259
const dir = join(home, ".corbits", "mcp-auth");

‎src/mcp/auth-store.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ const logger = getLogger([LOG_NAMESPACE_ROOT, "mcp", "auth-store"]);
2525
// Per-server OAuth state. The durable pair — registered client info and
2626
// tokens — reaches disk; the PKCE verifier stays instance-local in the
2727
// provider. Legacy codeVerifier keys are stripped on write.
28+
//
29+
// Issuer binding (SDK >= 1.31): tokens and client information carry a nested
30+
// `issuer`, the authorization server they belong to; both are persisted
31+
// unchanged in that nested shape, not as separate fields. Stores saved before
32+
// the stamp have no `issuer` key and still load — `issuer` is optional on
33+
// both — and the SDK consumes unstamped credentials as-is, re-stamping them
34+
// on the next successful auth or refresh. Stamping at read time is
35+
// deliberately not done: the persisted epoch predates binding, so the
36+
// authorization server cannot be reconstructed without discovery and a wrong
37+
// guess would make the SDK discard valid credentials.
2838
export interface MCPAuthState {
2939
clientInformation?: OAuthClientInformationFull;
3040
tokens?: OAuthTokens;

‎src/mcp/client-auth-policy.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,9 @@ describe("HTTP MCP auth policy", () => {
184184
});
185185
expect(callbackStarts).toBe(0);
186186
expect(providerCreates).toBe(0);
187-
expect(transportOptions).toEqual([undefined]);
187+
// No signal and no provider: leave redirectPolicy unset so the SDK
188+
// default `same-origin` applies. Do not pin `follow`.
189+
expect(transportOptions).toEqual([{}]);
188190
});
189191

190192
test("closes a bound OAuth callback server when provider setup fails", async () => {
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { mkdtemp } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5+
import { describe, expect, test } from "bun:test";
6+
import { saveAuthState } from "./auth-store.js";
7+
import { streamableHTTPTransportOptions } from "./client.js";
8+
import { createOAuthProvider } from "./oauth-provider.js";
9+
10+
const SERVER_URL = "https://mcp.example/mcp";
11+
const OFF_ORIGIN = "https://evil.example/collect";
12+
13+
function json(body: unknown): Response {
14+
return new Response(JSON.stringify(body), {
15+
status: 200,
16+
headers: { "content-type": "application/json" },
17+
});
18+
}
19+
20+
describe("HTTP MCP redirect policy", () => {
21+
test("discovery and token fetches do not follow a cross-origin redirect", async () => {
22+
const home = await mkdtemp(join(tmpdir(), "mcp-redirect-"));
23+
const identity = { serverName: "example", serverURL: SERVER_URL };
24+
await saveAuthState(
25+
identity,
26+
{
27+
clientInformation: {
28+
client_id: "client",
29+
redirect_uris: ["http://127.0.0.1:1/callback"],
30+
client_id_issued_at: 1,
31+
token_endpoint_auth_method: "none",
32+
grant_types: ["authorization_code", "refresh_token"],
33+
response_types: ["code"],
34+
client_name: "interchange-code",
35+
},
36+
},
37+
home,
38+
);
39+
const provider = await createOAuthProvider({
40+
serverName: identity.serverName,
41+
serverURL: identity.serverURL,
42+
redirectUrl: "http://127.0.0.1:1/callback",
43+
onAuthURL: () => undefined,
44+
home,
45+
});
46+
await provider.saveCodeVerifier("verifier");
47+
48+
const abort = new AbortController();
49+
const options = streamableHTTPTransportOptions(provider, abort.signal);
50+
expect(options).not.toHaveProperty("redirectPolicy");
51+
52+
const requested: string[] = [];
53+
const fetchFn = async (
54+
url: string | URL,
55+
init?: RequestInit,
56+
): Promise<Response> => {
57+
const href = String(url);
58+
requested.push(href);
59+
const target = new URL(href);
60+
if (target.hostname === "evil.example") {
61+
return new Response("collected", { status: 200 });
62+
}
63+
const crossOrigin =
64+
target.pathname === "/.well-known/oauth-protected-resource/mcp" ||
65+
target.pathname === "/oauth/token";
66+
if (!crossOrigin) {
67+
if (target.pathname === "/.well-known/oauth-protected-resource") {
68+
return json({
69+
resource: SERVER_URL,
70+
authorization_servers: ["https://mcp.example"],
71+
});
72+
}
73+
if (target.pathname === "/.well-known/oauth-authorization-server") {
74+
return json({
75+
issuer: "https://mcp.example",
76+
authorization_endpoint: "https://mcp.example/authorize",
77+
token_endpoint: "https://mcp.example/oauth/token",
78+
response_types_supported: ["code"],
79+
});
80+
}
81+
return new Response(null, { status: 404 });
82+
}
83+
const redirect = new Response(null, {
84+
status: 308,
85+
headers: { location: OFF_ORIGIN },
86+
});
87+
const mode = init?.redirect ?? "follow";
88+
if (mode === "error") throw new TypeError("redirect mode is error");
89+
if (mode === "manual") return redirect;
90+
return fetchFn(OFF_ORIGIN, init);
91+
};
92+
93+
const transport = new StreamableHTTPClientTransport(new URL(SERVER_URL), {
94+
...options,
95+
fetch: fetchFn,
96+
});
97+
await expect(transport.finishAuth("auth-code")).rejects.toThrow();
98+
99+
expect(requested.some((href) => href.includes("evil.example"))).toBe(false);
100+
expect(
101+
requested.some((href) =>
102+
href.includes("/.well-known/oauth-protected-resource/mcp"),
103+
),
104+
).toBe(true);
105+
expect(requested.some((href) => href.includes("/oauth/token"))).toBe(true);
106+
const tokenPost = requested.find((href) => href.includes("/oauth/token"));
107+
expect(tokenPost?.startsWith("https://mcp.example/")).toBe(true);
108+
});
109+
});

‎src/mcp/client.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,11 +405,13 @@ export function fetchWithConnectAbort(
405405
};
406406
}
407407

408-
function streamableHTTPTransportOptions(
408+
export function streamableHTTPTransportOptions(
409409
authProvider: CorbitsOAuthProvider | undefined,
410410
signal: AbortSignal | undefined,
411411
) {
412-
if (authProvider === undefined && signal === undefined) return undefined;
412+
// Leave redirectPolicy unset. SDK 1.32 defaults to `same-origin`; `follow`
413+
// also wraps OAuth discovery and token requests, so a 307/308 can post
414+
// refresh tokens or auth codes to another host.
413415
return {
414416
...(authProvider === undefined ? {} : { authProvider }),
415417
...(signal === undefined

‎src/mcp/oauth-provider.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -767,7 +767,7 @@ describe("createOAuthProvider", () => {
767767
).toBe("scoped-secret");
768768
});
769769

770-
test("refreshToken posts grant_type=refresh_token with a resource and persists tokens", async () => {
770+
test("refreshToken posts grant_type=refresh_token with a resource and persists issuer-bound tokens", async () => {
771771
const home = await tempHome();
772772
await saveAuthState(
773773
linear,
@@ -807,6 +807,7 @@ describe("createOAuthProvider", () => {
807807

808808
const tokens = await provider.refreshToken("refresh-me");
809809
expect(tokens.access_token).toBe("fresh-access");
810+
expect(tokens.issuer).toBe("https://mcp.linear.app");
810811
expect(tokenBodies).toHaveLength(1);
811812
const body = tokenBodies[0] ?? "";
812813
expect(body).toContain("grant_type=refresh_token");
@@ -820,6 +821,9 @@ describe("createOAuthProvider", () => {
820821
expect((await loadAuthState(linear, home)).tokens?.access_token).toBe(
821822
"fresh-access",
822823
);
824+
expect((await loadAuthState(linear, home)).tokens?.issuer).toBe(
825+
"https://mcp.linear.app",
826+
);
823827
});
824828

825829
test("refreshToken wraps failures as UnauthorizedError", async () => {

‎src/mcp/oauth-provider.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,10 +312,19 @@ export async function createOAuthProvider(
312312
resource: resourceURL,
313313
...(fetchFn === undefined ? {} : { fetchFn }),
314314
});
315+
// SDK >= 1.31 binds stored credentials to their authorization server
316+
// via an `issuer` stamp; the transport's auth() stamps its own saves,
317+
// so refresh here must too — unstamped tokens would re-trigger the
318+
// SDK's stored-tokens-without-issuer deprecation warning on the next
319+
// auth() and stay unbound.
320+
const boundTokens: OAuthTokens = {
321+
...tokens,
322+
issuer: authorizationServerUrl,
323+
};
315324
await apply((state) => {
316-
state.tokens = tokens;
325+
state.tokens = boundTokens;
317326
});
318-
return tokens;
327+
return boundTokens;
319328
} catch (err) {
320329
if (isAbortError(err) || err instanceof UnauthorizedError) throw err;
321330
throw new UnauthorizedError(

0 commit comments

Comments
 (0)