Skip to content

Commit d897ee8

Browse files
authored
Merge branch 'main' into main
2 parents 81570d2 + 0506add commit d897ee8

File tree

14 files changed

+200
-49
lines changed

14 files changed

+200
-49
lines changed

eslint.config.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,12 @@ export default tseslint.config(
1515
{ "argsIgnorePattern": "^_" }
1616
]
1717
}
18+
},
19+
{
20+
files: ["src/client/**/*.ts", "src/server/**/*.ts"],
21+
ignores: ["**/*.test.ts"],
22+
rules: {
23+
"no-console": "error"
24+
}
1825
}
1926
);

src/client/auth.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1041,9 +1041,70 @@ describe("OAuth Authorization", () => {
10411041

10421042
// Verify custom validation method was called
10431043
expect(mockValidateResourceURL).toHaveBeenCalledWith(
1044-
"https://api.example.com/mcp-server",
1044+
new URL("https://api.example.com/mcp-server"),
10451045
"https://different-resource.example.com/mcp-server"
10461046
);
10471047
});
1048+
1049+
it("uses prefix of server URL from PRM resource as resource parameter", async () => {
1050+
// Mock successful metadata discovery with resource URL that is a prefix of requested URL
1051+
mockFetch.mockImplementation((url) => {
1052+
const urlString = url.toString();
1053+
1054+
if (urlString.includes("/.well-known/oauth-protected-resource")) {
1055+
return Promise.resolve({
1056+
ok: true,
1057+
status: 200,
1058+
json: async () => ({
1059+
// Resource is a prefix of the requested server URL
1060+
resource: "https://api.example.com/",
1061+
authorization_servers: ["https://auth.example.com"],
1062+
}),
1063+
});
1064+
} else if (urlString.includes("/.well-known/oauth-authorization-server")) {
1065+
return Promise.resolve({
1066+
ok: true,
1067+
status: 200,
1068+
json: async () => ({
1069+
issuer: "https://auth.example.com",
1070+
authorization_endpoint: "https://auth.example.com/authorize",
1071+
token_endpoint: "https://auth.example.com/token",
1072+
response_types_supported: ["code"],
1073+
code_challenge_methods_supported: ["S256"],
1074+
}),
1075+
});
1076+
}
1077+
1078+
return Promise.resolve({ ok: false, status: 404 });
1079+
});
1080+
1081+
// Mock provider methods
1082+
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
1083+
client_id: "test-client",
1084+
client_secret: "test-secret",
1085+
});
1086+
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
1087+
(mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined);
1088+
(mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined);
1089+
1090+
// Call auth with a URL that has the resource as prefix
1091+
const result = await auth(mockProvider, {
1092+
serverUrl: "https://api.example.com/mcp-server/endpoint",
1093+
});
1094+
1095+
expect(result).toBe("REDIRECT");
1096+
1097+
// Verify the authorization URL includes the resource parameter from PRM
1098+
expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith(
1099+
expect.objectContaining({
1100+
searchParams: expect.any(URLSearchParams),
1101+
})
1102+
);
1103+
1104+
const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0];
1105+
const authUrl: URL = redirectCall[0];
1106+
// Should use the PRM's resource value, not the full requested URL
1107+
expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/");
1108+
});
10481109
});
10491110
});

src/client/auth.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import pkceChallenge from "pkce-challenge";
22
import { LATEST_PROTOCOL_VERSION } from "../types.js";
33
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js";
44
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
5-
import { resourceUrlFromServerUrl } from "../shared/auth-utils.js";
5+
import { checkResourceAllowed, resourceUrlFromServerUrl } from "../shared/auth-utils.js";
66

77
/**
88
* Implements an end-to-end OAuth client to be used with one MCP server.
@@ -116,8 +116,8 @@ export async function auth(
116116
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
117117
authorizationServerUrl = resourceMetadata.authorization_servers[0];
118118
}
119-
} catch (error) {
120-
console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error)
119+
} catch {
120+
// Ignore errors and fall back to /.well-known/oauth-authorization-server
121121
}
122122

123123
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
@@ -175,8 +175,8 @@ export async function auth(
175175

176176
await provider.saveTokens(newTokens);
177177
return "AUTHORIZED";
178-
} catch (error) {
179-
console.error("Could not refresh OAuth tokens:", error);
178+
} catch {
179+
// Could not refresh OAuth tokens
180180
}
181181
}
182182

@@ -197,14 +197,17 @@ export async function auth(
197197
return "REDIRECT";
198198
}
199199

200-
async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise<URL | undefined> {
200+
export async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise<URL | undefined> {
201+
let resource = resourceUrlFromServerUrl(serverUrl);
201202
if (provider.validateResourceURL) {
202-
return await provider.validateResourceURL(serverUrl, resourceMetadata?.resource);
203-
}
204-
205-
const resource = resourceUrlFromServerUrl(typeof serverUrl === "string" ? new URL(serverUrl) : serverUrl);
206-
if (resourceMetadata && resourceMetadata.resource !== resource.href) {
207-
throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource}`);
203+
return await provider.validateResourceURL(resource, resourceMetadata?.resource);
204+
} else if (resourceMetadata) {
205+
if (checkResourceAllowed({ requestedResource: resource, configuredResource: resourceMetadata.resource })) {
206+
// If the resource mentioned in metadata is valid, prefer it since it is what the server is telling us to request.
207+
resource = new URL(resourceMetadata.resource);
208+
} else {
209+
throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource} (or origin)`);
210+
}
208211
}
209212

210213
return resource;
@@ -222,7 +225,6 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined {
222225

223226
const [type, scheme] = authenticateHeader.split(' ');
224227
if (type.toLowerCase() !== 'bearer' || !scheme) {
225-
console.log("Invalid WWW-Authenticate header format, expected 'Bearer'");
226228
return undefined;
227229
}
228230
const regex = /resource_metadata="([^"]*)"/;
@@ -235,7 +237,6 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined {
235237
try {
236238
return new URL(match[1]);
237239
} catch {
238-
console.log("Invalid resource metadata url: ", match[1]);
239240
return undefined;
240241
}
241242
}

src/client/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -486,8 +486,8 @@ export class Client<
486486
try {
487487
const validator = this._ajv.compile(tool.outputSchema);
488488
this._cachedToolOutputValidators.set(tool.name, validator);
489-
} catch (error) {
490-
console.warn(`Failed to compile output schema for tool ${tool.name}: ${error}`);
489+
} catch {
490+
// Ignore schema compilation errors
491491
}
492492
}
493493
}

src/client/sse.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,23 +117,35 @@ export class SSEClientTransport implements Transport {
117117
}
118118

119119
private _startOrAuth(): Promise<void> {
120+
const fetchImpl = (this?._eventSourceInit?.fetch || fetch) as typeof fetch
120121
return new Promise((resolve, reject) => {
121122
this._eventSource = new EventSource(
122123
this._url.href,
123-
this._eventSourceInit ?? {
124-
fetch: (url, init) => this._commonHeaders().then((headers) => fetch(url, {
125-
...init,
126-
headers: {
127-
...headers,
128-
Accept: "text/event-stream"
124+
{
125+
...this._eventSourceInit,
126+
fetch: async (url, init) => {
127+
const headers = await this._commonHeaders()
128+
const response = await fetchImpl(url, {
129+
...init,
130+
headers: new Headers({
131+
...headers,
132+
Accept: "text/event-stream"
133+
})
134+
})
135+
136+
if (response.status === 401 && response.headers.has('www-authenticate')) {
137+
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
129138
}
130-
})),
139+
140+
return response
141+
},
131142
},
132143
);
133144
this._abortController = new AbortController();
134145

135146
this._eventSource.onerror = (event) => {
136147
if (event.code === 401 && this._authProvider) {
148+
137149
this._authThenStart().then(resolve, reject);
138150
return;
139151
}

src/examples/server/simpleStreamableHttp.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CallToolResult, GetPromptResult, isInitializeRequest, PrimitiveSchemaDe
99
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
1010
import { setupAuthServer } from './demoInMemoryOAuthProvider.js';
1111
import { OAuthMetadata } from 'src/shared/auth.js';
12+
import { checkResourceAllowed } from 'src/shared/auth-utils.js';
1213

1314
// Check for OAuth flag
1415
const useOAuth = process.argv.includes('--oauth');
@@ -463,7 +464,7 @@ if (useOAuth) {
463464
if (!data.aud) {
464465
throw new Error(`Resource Indicator (RFC8707) missing`);
465466
}
466-
if (data.aud !== mcpServerUrl.href) {
467+
if (!checkResourceAllowed({ requestedResource: data.aud, configuredResource: mcpServerUrl })) {
467468
throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`);
468469
}
469470
}

src/server/auth/handlers/authorize.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
9999
const status = error instanceof ServerError ? 500 : 400;
100100
res.status(status).json(error.toResponseObject());
101101
} else {
102-
console.error("Unexpected error looking up client:", error);
103102
const serverError = new ServerError("Internal Server Error");
104103
res.status(500).json(serverError.toResponseObject());
105104
}
@@ -146,7 +145,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
146145
if (error instanceof OAuthError) {
147146
res.redirect(302, createErrorRedirect(redirect_uri, error, state));
148147
} else {
149-
console.error("Unexpected error during authorization:", error);
150148
const serverError = new ServerError("Internal Server Error");
151149
res.redirect(302, createErrorRedirect(redirect_uri, serverError, state));
152150
}

src/server/auth/handlers/register.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,6 @@ export function clientRegistrationHandler({
104104
const status = error instanceof ServerError ? 500 : 400;
105105
res.status(status).json(error.toResponseObject());
106106
} else {
107-
console.error("Unexpected error registering client:", error);
108107
const serverError = new ServerError("Internal Server Error");
109108
res.status(500).json(serverError.toResponseObject());
110109
}

src/server/auth/handlers/revoke.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
InvalidRequestError,
1010
ServerError,
1111
TooManyRequestsError,
12-
OAuthError
12+
OAuthError,
1313
} from "../errors.js";
1414

1515
export type RevocationHandlerOptions = {
@@ -21,7 +21,10 @@ export type RevocationHandlerOptions = {
2121
rateLimit?: Partial<RateLimitOptions> | false;
2222
};
2323

24-
export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler {
24+
export function revocationHandler({
25+
provider,
26+
rateLimit: rateLimitConfig,
27+
}: RevocationHandlerOptions): RequestHandler {
2528
if (!provider.revokeToken) {
2629
throw new Error("Auth provider does not support revoking tokens");
2730
}
@@ -37,21 +40,25 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo
3740

3841
// Apply rate limiting unless explicitly disabled
3942
if (rateLimitConfig !== false) {
40-
router.use(rateLimit({
41-
windowMs: 15 * 60 * 1000, // 15 minutes
42-
max: 50, // 50 requests per windowMs
43-
standardHeaders: true,
44-
legacyHeaders: false,
45-
message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(),
46-
...rateLimitConfig
47-
}));
43+
router.use(
44+
rateLimit({
45+
windowMs: 15 * 60 * 1000, // 15 minutes
46+
max: 50, // 50 requests per windowMs
47+
standardHeaders: true,
48+
legacyHeaders: false,
49+
message: new TooManyRequestsError(
50+
"You have exceeded the rate limit for token revocation requests"
51+
).toResponseObject(),
52+
...rateLimitConfig,
53+
})
54+
);
4855
}
4956

5057
// Authenticate and extract client details
5158
router.use(authenticateClient({ clientsStore: provider.clientsStore }));
5259

5360
router.post("/", async (req, res) => {
54-
res.setHeader('Cache-Control', 'no-store');
61+
res.setHeader("Cache-Control", "no-store");
5562

5663
try {
5764
const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body);
@@ -62,7 +69,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo
6269
const client = req.client;
6370
if (!client) {
6471
// This should never happen
65-
console.error("Missing client information after authentication");
6672
throw new ServerError("Internal Server Error");
6773
}
6874

@@ -73,7 +79,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo
7379
const status = error instanceof ServerError ? 500 : 400;
7480
res.status(status).json(error.toResponseObject());
7581
} else {
76-
console.error("Unexpected error revoking token:", error);
7782
const serverError = new ServerError("Internal Server Error");
7883
res.status(500).json(serverError.toResponseObject());
7984
}

src/server/auth/handlers/token.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
8080
const client = req.client;
8181
if (!client) {
8282
// This should never happen
83-
console.error("Missing client information after authentication");
8483
throw new ServerError("Internal Server Error");
8584
}
8685

@@ -143,7 +142,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
143142
const status = error instanceof ServerError ? 500 : 400;
144143
res.status(status).json(error.toResponseObject());
145144
} else {
146-
console.error("Unexpected error exchanging token:", error);
147145
const serverError = new ServerError("Internal Server Error");
148146
res.status(500).json(serverError.toResponseObject());
149147
}

0 commit comments

Comments
 (0)