Skip to content

Commit 0b3f1bc

Browse files
committed
feat: support oidc discovery in client sdk
1 parent 9b99ffb commit 0b3f1bc

File tree

3 files changed

+96
-46
lines changed

3 files changed

+96
-46
lines changed

src/client/auth.test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ describe("OAuth Authorization", () => {
189189
code_challenge_methods_supported: ["S256"],
190190
};
191191

192-
it("returns metadata when discovery succeeds", async () => {
192+
it("returns metadata when oauth-authorization-server discovery succeeds", async () => {
193193
mockFetch.mockResolvedValueOnce({
194194
ok: true,
195195
status: 200,
@@ -207,6 +207,28 @@ describe("OAuth Authorization", () => {
207207
});
208208
});
209209

210+
it("returns metadata when oidc discovery succeeds", async () => {
211+
mockFetch.mockImplementation((url) => {
212+
if (url.toString().includes('openid-configuration')) {
213+
return Promise.resolve({
214+
ok: true,
215+
status: 200,
216+
json: async () => validMetadata,
217+
});
218+
}
219+
return Promise.resolve({
220+
ok: false,
221+
status: 404,
222+
});
223+
});
224+
225+
const metadata = await discoverOAuthMetadata("https://auth.example.com");
226+
expect(metadata).toEqual(validMetadata);
227+
expect(mockFetch).toHaveBeenCalledTimes(2);
228+
expect(mockFetch.mock.calls[0][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
229+
expect(mockFetch.mock.calls[1][0].toString()).toBe("https://auth.example.com/.well-known/openid-configuration");
230+
});
231+
210232
it("returns metadata when first fetch fails but second without MCP header succeeds", async () => {
211233
// Set up a counter to control behavior
212234
let callCount = 0;
@@ -266,13 +288,14 @@ describe("OAuth Authorization", () => {
266288
});
267289

268290
it("returns undefined when discovery endpoint returns 404", async () => {
269-
mockFetch.mockResolvedValueOnce({
291+
mockFetch.mockResolvedValue({
270292
ok: false,
271293
status: 404,
272294
});
273295

274296
const metadata = await discoverOAuthMetadata("https://auth.example.com");
275297
expect(metadata).toBeUndefined();
298+
expect(mockFetch).toHaveBeenCalledTimes(2);
276299
});
277300

278301
it("throws on non-404 errors", async () => {

src/client/auth.ts

Lines changed: 63 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -230,22 +230,8 @@ export async function discoverOAuthProtectedResourceMetadata(
230230
} else {
231231
url = new URL("/.well-known/oauth-protected-resource", serverUrl);
232232
}
233-
234-
let response: Response;
235-
try {
236-
response = await fetch(url, {
237-
headers: {
238-
"MCP-Protocol-Version": opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION
239-
}
240-
});
241-
} catch (error) {
242-
// CORS errors come back as TypeError
243-
if (error instanceof TypeError) {
244-
response = await fetch(url);
245-
} else {
246-
throw error;
247-
}
248-
}
233+
234+
const response = await fetchWithCorsFallback(url, opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION);
249235

250236
if (response.status === 404) {
251237
throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`);
@@ -260,43 +246,59 @@ export async function discoverOAuthProtectedResourceMetadata(
260246
}
261247

262248
/**
263-
* Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata.
249+
* Looks up authorization server metadata from an MCP-compliant server.
264250
*
265-
* If the server returns a 404 for the well-known endpoint, this function will
251+
* Per the MCP specification, clients **MUST** support both OAuth 2.0
252+
* Authorization Server Metadata ([RFC8414](https://datatracker.ietf.org/doc/html/rfc8414))
253+
* and [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0-final.html).
254+
* This function implements this requirement by checking the well-known
255+
* discovery endpoints for both standards.
256+
*
257+
* The function can parse responses from both types of endpoints because OIDC
258+
* discovery metadata is a superset of the metadata defined in RFC 8414.
259+
*
260+
* If the server returns a 404 for all known endpoints, this function will
266261
* return `undefined`. Any other errors will be thrown as exceptions.
267262
*/
268263
export async function discoverOAuthMetadata(
269264
authorizationServerUrl: string | URL,
270265
opts?: { protocolVersion?: string },
271266
): Promise<OAuthMetadata | undefined> {
272-
const url = new URL("/.well-known/oauth-authorization-server", authorizationServerUrl);
273-
let response: Response;
274-
try {
275-
response = await fetch(url, {
276-
headers: {
277-
"MCP-Protocol-Version": opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION
278-
}
279-
});
280-
} catch (error) {
281-
// CORS errors come back as TypeError
282-
if (error instanceof TypeError) {
283-
response = await fetch(url);
284-
} else {
285-
throw error;
267+
268+
/**
269+
* To support both OIDC and plain OAuth2 servers, this checks for their
270+
* respective discovery endpoints.
271+
*/
272+
const potentialAuthServerMetadataUrls = [
273+
new URL("/.well-known/oauth-authorization-server", authorizationServerUrl),
274+
new URL("/.well-known/openid-configuration", authorizationServerUrl),
275+
];
276+
277+
for (const url of potentialAuthServerMetadataUrls) {
278+
const response = await fetchWithCorsFallback(url, opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION);
279+
280+
if (response.status === 404) {
281+
// Try the next URL
282+
continue;
286283
}
287-
}
288284

289-
if (response.status === 404) {
290-
return undefined;
291-
}
285+
if (!response.ok) {
286+
throw new Error(
287+
`HTTP ${response.status} trying to load well-known OAuth metadata`,
288+
);
289+
}
292290

293-
if (!response.ok) {
294-
throw new Error(
295-
`HTTP ${response.status} trying to load well-known OAuth metadata`,
296-
);
291+
/**
292+
* The `OAuthMetadataSchema` is compatible with both OIDC and OAuth2
293+
* discovery responses. Because OIDC's metadata is a superset, `zod` will
294+
* correctly parse the fields defined in our schema and simply ignore any
295+
* additional OIDC-specific fields.
296+
*/
297+
return OAuthMetadataSchema.parse(await response.json());
297298
}
298299

299-
return OAuthMetadataSchema.parse(await response.json());
300+
// If all URLs returned 404, discovery is not supported by the server.
301+
return undefined;
300302
}
301303

302304
/**
@@ -530,3 +532,23 @@ export async function registerClient(
530532

531533
return OAuthClientInformationFullSchema.parse(await response.json());
532534
}
535+
536+
/**
537+
* A fetch wrapper that attempts to set the MCP-Protocol-Version header, but
538+
* falls back to a header-less request if a cors error occurs.
539+
*/
540+
const fetchWithCorsFallback = async (url: URL, protocolVersion: string) => {
541+
try {
542+
return await fetch(url, {
543+
headers: {
544+
"MCP-Protocol-Version": protocolVersion
545+
}
546+
})
547+
} catch (error) {
548+
if (error instanceof TypeError) {
549+
// CORS errors come back as TypeError, try again without protocol version header
550+
return await fetch(url);
551+
}
552+
throw error;
553+
}
554+
}

src/client/sse.test.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,11 @@ describe("SSEClientTransport", () => {
319319
});
320320

321321
describe("auth handling", () => {
322+
const authServerMetadataUrls = [
323+
"/.well-known/oauth-authorization-server",
324+
"/.well-known/openid-configuration",
325+
];
326+
322327
let mockAuthProvider: jest.Mocked<OAuthClientProvider>;
323328

324329
beforeEach(() => {
@@ -551,7 +556,7 @@ describe("SSEClientTransport", () => {
551556
authServer.close();
552557

553558
authServer = createServer((req, res) => {
554-
if (req.url === "/.well-known/oauth-authorization-server") {
559+
if (req.url && authServerMetadataUrls.includes(req.url)) {
555560
res.writeHead(404).end();
556561
return;
557562
}
@@ -673,7 +678,7 @@ describe("SSEClientTransport", () => {
673678
authServer.close();
674679

675680
authServer = createServer((req, res) => {
676-
if (req.url === "/.well-known/oauth-authorization-server") {
681+
if (req.url && authServerMetadataUrls.includes(req.url)) {
677682
res.writeHead(404).end();
678683
return;
679684
}
@@ -818,7 +823,7 @@ describe("SSEClientTransport", () => {
818823
authServer.close();
819824

820825
authServer = createServer((req, res) => {
821-
if (req.url === "/.well-known/oauth-authorization-server") {
826+
if (req.url && authServerMetadataUrls.includes(req.url)) {
822827
res.writeHead(404).end();
823828
return;
824829
}

0 commit comments

Comments
 (0)