Skip to content
This repository was archived by the owner on Jul 8, 2026. It is now read-only.

Commit 81d51a7

Browse files
authored
Merge pull request #162 from useshortcut/kurt/sc-308975/fix-mcp-transport-error-during-codex
sc-308975: fix MCP transport error during Codex startup
2 parents cc28650 + 83403ed commit 81d51a7

4 files changed

Lines changed: 360 additions & 23 deletions

File tree

‎src/auth/oauth.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,26 @@ describe("OAuth Flow Tests", () => {
627627
}),
628628
});
629629
expect(mcpRes.status).toBe(200);
630+
const sessionId = mcpRes.headers.get("mcp-session-id");
631+
expect(sessionId).toBeDefined();
632+
633+
// Step 7: Send initialized notification using the same bearer token.
634+
// This verifies the server accepts follow-up session messages even if
635+
// middleware refreshes token metadata internally.
636+
const initializedRes = await fetch(`${baseUrl}/mcp`, {
637+
method: "POST",
638+
headers: {
639+
"Content-Type": "application/json",
640+
Authorization: `Bearer ${tokens.access_token}`,
641+
"Mcp-Session-Id": sessionId ?? "",
642+
},
643+
body: JSON.stringify({
644+
jsonrpc: "2.0",
645+
method: "notifications/initialized",
646+
params: {},
647+
}),
648+
});
649+
expect([200, 202, 204]).toContain(initializedRes.status);
630650
});
631651
});
632652
});

‎src/auth/provider.test.ts‎

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { beforeEach, describe, expect, mock, test } from "bun:test";
2+
import {
3+
InvalidGrantError,
4+
InvalidTokenError,
5+
} from "@modelcontextprotocol/sdk/server/auth/errors.js";
6+
7+
type HeadersMap = Record<string, unknown>;
8+
9+
const mockState: {
10+
handler: (headers: HeadersMap) => Promise<{ data: { id: string; mention_name: string } }>;
11+
calls: HeadersMap[];
12+
} = {
13+
handler: async () => {
14+
throw new Error("Unauthorized");
15+
},
16+
calls: [],
17+
};
18+
19+
class MockShortcutClient {
20+
instance: { defaults: { headers: HeadersMap } };
21+
22+
constructor(token: string, config?: { headers?: Record<string, string> }) {
23+
const configuredHeaders = config?.headers ?? {};
24+
this.instance = {
25+
defaults: {
26+
headers: {
27+
...configuredHeaders,
28+
"Shortcut-Token": token,
29+
common: {
30+
...configuredHeaders,
31+
"Shortcut-Token": token,
32+
},
33+
},
34+
},
35+
};
36+
}
37+
38+
async getCurrentMemberInfo(): Promise<{ data: { id: string; mention_name: string } }> {
39+
const headers = this.instance.defaults.headers;
40+
const common =
41+
typeof headers.common === "object" && headers.common ? (headers.common as HeadersMap) : {};
42+
43+
mockState.calls.push({
44+
...headers,
45+
common: { ...common },
46+
});
47+
48+
return mockState.handler(headers);
49+
}
50+
}
51+
52+
mock.module("@shortcut/client", () => ({ ShortcutClient: MockShortcutClient }));
53+
54+
const { createOAuthProvider } = await import("./provider");
55+
56+
function getHeader(headers: HeadersMap, name: string): unknown {
57+
const common =
58+
typeof headers.common === "object" && headers.common ? (headers.common as HeadersMap) : {};
59+
60+
return headers[name] ?? headers[name.toLowerCase()] ?? common[name] ?? common[name.toLowerCase()];
61+
}
62+
63+
describe("createOAuthProvider default verifier", () => {
64+
beforeEach(() => {
65+
process.env.SHORTCUT_OAUTH_CLIENT_ID = "test-client-id";
66+
process.env.SHORTCUT_OAUTH_CLIENT_SECRET = "test-client-secret";
67+
process.env.AUTH_SERVER = "api.app.shortcut-staging.com";
68+
mockState.calls = [];
69+
});
70+
71+
test("verifies uncached OAuth token via Authorization bearer header", async () => {
72+
mockState.handler = async (headers) => {
73+
if (getHeader(headers, "Authorization") === "Bearer oauth-token") {
74+
return {
75+
data: {
76+
id: "member-1",
77+
mention_name: "oauth-user",
78+
},
79+
};
80+
}
81+
throw new Error("Unauthorized");
82+
};
83+
84+
const provider = createOAuthProvider();
85+
const authInfo = await provider.verifyAccessToken("oauth-token");
86+
87+
expect(authInfo.token).toBe("oauth-token");
88+
expect(authInfo.clientId).toBe("test-client-id");
89+
expect(authInfo.extra).toEqual({
90+
memberId: "member-1",
91+
mentionName: "oauth-user",
92+
});
93+
expect(mockState.calls.length).toBe(1);
94+
expect(getHeader(mockState.calls[0], "Shortcut-Token")).toBeUndefined();
95+
});
96+
97+
test("falls back to legacy Shortcut-Token verification when bearer auth fails", async () => {
98+
mockState.handler = async (headers) => {
99+
if (getHeader(headers, "Authorization") === "Bearer legacy-token") {
100+
throw new Error("Bearer rejected");
101+
}
102+
if (getHeader(headers, "Shortcut-Token") === "legacy-token") {
103+
return {
104+
data: {
105+
id: "member-2",
106+
mention_name: "legacy-user",
107+
},
108+
};
109+
}
110+
throw new Error("Unauthorized");
111+
};
112+
113+
const provider = createOAuthProvider();
114+
const authInfo = await provider.verifyAccessToken("legacy-token");
115+
116+
expect(authInfo.token).toBe("legacy-token");
117+
expect(authInfo.extra).toEqual({
118+
memberId: "member-2",
119+
mentionName: "legacy-user",
120+
});
121+
expect(mockState.calls.length).toBe(2);
122+
});
123+
124+
test("throws InvalidTokenError when both bearer and legacy verification fail", async () => {
125+
mockState.handler = async () => {
126+
throw new Error("Unauthorized");
127+
};
128+
129+
const provider = createOAuthProvider();
130+
await expect(provider.verifyAccessToken("invalid-token")).rejects.toBeInstanceOf(
131+
InvalidTokenError,
132+
);
133+
expect(mockState.calls.length).toBe(2);
134+
});
135+
});
136+
137+
describe("createOAuthProvider token exchange behavior", () => {
138+
beforeEach(() => {
139+
process.env.SHORTCUT_OAUTH_CLIENT_ID = "test-client-id";
140+
process.env.SHORTCUT_OAUTH_CLIENT_SECRET = "test-client-secret";
141+
process.env.AUTH_SERVER = "api.app.shortcut-staging.com";
142+
});
143+
144+
test("maps upstream invalid_grant refresh errors to InvalidGrantError", async () => {
145+
const fetchMock = mock(async () => {
146+
return new Response(
147+
JSON.stringify({
148+
error: "invalid_grant",
149+
error_description: "Refresh token expired",
150+
}),
151+
{
152+
status: 400,
153+
headers: { "Content-Type": "application/json" },
154+
},
155+
);
156+
});
157+
158+
const provider = createOAuthProvider({
159+
fetch: fetchMock as unknown as FetchLike,
160+
endpoints: {
161+
authorizationUrl: "https://example.com/oauth/code",
162+
tokenUrl: "https://example.com/oauth/token",
163+
},
164+
});
165+
166+
await expect(
167+
provider.exchangeRefreshToken(
168+
{
169+
client_id: "test-client-id",
170+
redirect_uris: [],
171+
} as never,
172+
"expired-refresh-token",
173+
),
174+
).rejects.toBeInstanceOf(InvalidGrantError);
175+
expect(fetchMock).toHaveBeenCalledTimes(1);
176+
});
177+
178+
test("normalizes refresh token responses without expires_in", async () => {
179+
const fetchMock = mock(async () => {
180+
return new Response(
181+
JSON.stringify({
182+
access_token: "new-access-token",
183+
refresh_token: "new-refresh-token",
184+
scope: "openid",
185+
}),
186+
{
187+
status: 200,
188+
headers: { "Content-Type": "application/json" },
189+
},
190+
);
191+
});
192+
193+
const provider = createOAuthProvider({
194+
fetch: fetchMock as unknown as FetchLike,
195+
endpoints: {
196+
authorizationUrl: "https://example.com/oauth/code",
197+
tokenUrl: "https://example.com/oauth/token",
198+
},
199+
});
200+
201+
const tokens = await provider.exchangeRefreshToken(
202+
{
203+
client_id: "test-client-id",
204+
redirect_uris: [],
205+
} as never,
206+
"valid-refresh-token",
207+
);
208+
209+
expect(tokens.token_type).toBe("Bearer");
210+
expect(tokens.expires_in).toBe(3600);
211+
expect(tokens.access_token).toBe("new-access-token");
212+
});
213+
});
214+
215+
type FetchLike = (url: string | URL, init?: RequestInit) => Promise<Response>;

0 commit comments

Comments
 (0)