8000 RFC 8707 Resource Indicators Implementation by ochafik · Pull Request #638 · modelcontextprotocol/typescript-sdk · GitHub
[go: up one dir, main page]

Skip to content

RFC 8707 Resource Indicators Implementation #638

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 41 commits into from
Jun 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
7b5bfce
implementation of RFC 8707 Resource Indicators (Fixes #592, Fixes #635)
ochafik Jun 16, 2025
1f4e42c
cleanup auth-utils and remove example files
ochafik Jun 16, 2025
cba6a6e
Update authorize.config.test.ts
ochafik Jun 16, 2025
ccccb4b
simplify PR / only keep verification in demo inmemory oauth provider
ochafik Jun 16, 2025
e542ec1
docs: update PR description to clarify server-side validation is in d…
ochafik Jun 16, 2025
6656d23
Simplify demo in-memory oauth provider
ochafik Jun 16, 2025
02ce81b
simplify diff
ochafik Jun 16, 2025
36f338a
Update demoInMemoryOAuthProvider.ts
ochafik Jun 16, 2025
224a2e2
update resource to be a url
ochafik Jun 16, 2025
551a439
use URL for resource throughout
ochafik Jun 16, 2025
6e4fc52
Update demoInMemoryOAuthProvider.test.ts
ochafik Jun 16, 2025
ec0c504
rm noise
ochafik Jun 17, 2025
b16a415
cleanups
ochafik Jun 17, 2025
badb5dc
fix tests
ochafik Jun 17, 2025
515abb4
fix lints
ochafik Jun 17, 2025
40f61d8
show how to enable strict resource checking in mcp server
ochafik Jun 17, 2025
617facc
Add test for default protocol version negotiation in bearerAuth middl…
ochafik Jun 17, 2025
c2150f0
Update README.md
ochafik Jun 17, 2025
bf72f87
cleanups
ochafik Jun 18, 2025
4a88cac
Update simpleStreamableHttp.ts
ochafik Jun 18, 2025
d58c2eb
Update simpleStreamableHttp.ts
ochafik Jun 18, 2025
aebb2ab
Update simpleStreamableHttp.ts
ochafik Jun 18, 2025
049170d
minimize diff
ochafik Jun 18, 2025
b77361b
Update streamableHttp.ts
ochafik Jun 18, 2025
8475e43
drop redundant resource canonicalization tests
ochafik Jun 18, 2025
e5b2a5b
fix simpleStreamableHttp.ts
ochafik Jun 18, 2025
4fcbb68
verify PRM resource
ochafik Jun 18, 2025
68424ef
simplify changes
ochafik Jun 18, 2025
9e2a565
minimize changes
ochafik Jun 18, 2025
6a01d0d
shrink token.test.ts
ochafik Jun 18, 2025
5c60c77
shrink diff
ochafik Jun 18, 2025
354318f
auth: don't fail the prm if the resource doesn't match
ochafik Jun 18, 2025
bac384f
simplify tests
ochafik Jun 18, 2025
a7f9c59
Fix SSE test resource URL validation errors
ochafik Jun 18, 2025
f0ea31c
Update auth.test.ts
ochafik Jun 18, 2025
3f07bdb
shrink tests
ochafik Jun 18, 2025
4b3db9b
stricter PRM check overridable w/ OAuthClientProvider.validateProtect…
ochafik Jun 18, 2025
f854b58
test validateProtectedResourceMetadata override
ochafik Jun 18, 2025
dada5f6
wip helper func
pcarleton Jun 18, 2025
4c51230
fix tests
pcarleton Jun 18, 2025
86bed6a
adjust comment
pcarleton Jun 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 237 additions & 0 deletions src/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ describe("OAuth Authorization", () => {
metadata: undefined,
clientInformation: validClientInfo,
redirectUrl: "http://localhost:3000/callback",
resource: new URL("https://api.example.com/mcp-server"),
}
);

Expand All @@ -338,6 +339,7 @@ describe("OAuth Authorization", () => {
expect(authorizationUrl.searchParams.get("redirect_uri")).toBe(
"http://localhost:3000/callback"
);
expect(authorizationUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server");
expect(codeVerifier).toBe("test_verifier");
});

Expand Down Expand Up @@ -465,6 +467,7 @@ describe("OAuth Authorization", () => {
authorizationCode: "code123",
codeVerifier: "verifier123",
redirectUri: "http://localhost:3000/callback",
resource: new URL("https://api.example.com/mcp-server"),
});

expect(tokens).toEqual(validTokens);
Expand All @@ -487,6 +490,7 @@ describe("OAuth Authorization", () => {
expect(body.get("client_id")).toBe("client123");
expect(body.get("client_secret")).toBe("secret123");
expect(body.get("redirect_uri")).toBe("http://localhost:3000/callback");
expect(body.get("resource")).toBe("https://api.example.com/mcp-server");
});

it("validates token response schema", async () => {
Expand Down Expand Up @@ -554,6 +558,7 @@ describe("OAuth Authorization", () => {
const tokens = await refreshAuthorization("https://auth.example.com", {
clientInformation: validClientInfo,
refreshToken: "refresh123",
resource: new URL("https://api.example.com/mcp-server"),
});

expect(tokens).toEqual(validTokensWithNewRefreshToken);
Expand All @@ -574,6 +579,7 @@ describe("OAuth Authorization", () => {
expect(body.get("refresh_token")).toBe("refresh123");
expect(body.get("client_id")).toBe("client123");
expect(body.get("client_secret")).toBe("secret123");
expect(body.get("resource")).toBe("https://api.example.com/mcp-server");
});

it("exchanges refresh token for new tokens and keep existing refresh token if none is returned", async () => {
Expand Down Expand Up @@ -807,5 +813,236 @@ describe("OAuth Authorization", () => {
"https://resource.example.com/.well-known/oauth-authorization-server"
);
});

it("passes resource parameter through authorization flow", async () => {
// Mock successful metadata discovery
mockFetch.mockImplementation((url) => {
const urlString = url.toString();
if (urlString.includes("/.well-known/oauth-authorization-server")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
});
}
return Promise.resolve({ ok: false, status: 404 });
});

// Mock provider methods for authorization flow
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
client_id: "test-client",
client_secret: "test-secret",
});
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
(mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined);
(mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined);

// Call auth without authorization code (should trigger redirect)
const result = await auth(mockProvider, {
serverUrl: "https://api.example.com/mcp-server",
});

expect(result).toBe("REDIRECT");

// Verify the authorization URL includes the resource parameter
expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith(
expect.objectContaining({
searchParams: expect.any(URLSearchParams),
})
);

const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0];
const authUrl: URL = redirectCall[0];
expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server");
});

it("includes resource in token exchange when authorization code is provided", async () => {
// Mock successful metadata discovery and token exchange
mockFetch.mockImplementation((url) => {
const urlString = url.toString();

if (urlString.includes("/.well-known/oauth-authorization-server")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
});
} else if (urlString.includes("/token")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
access_token: "access123",
token_type: "Bearer",
expires_in: 3600,
refresh_token: "refresh123",
}),
});
}

return Promise.resolve({ ok: false, status: 404 });
});

// Mock provider methods for token exchange
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
client_id: "test-client",
client_secret: "test-secret",
});
(mockProvider.codeVerifier as jest.Mock).mockResolvedValue("test-verifier");
(mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined);

// Call auth with authorization code
const result = await auth(mockProvider, {
serverUrl: "https://api.example.com/mcp-server",
authorizationCode: "auth-code-123",
});

expect(result).toBe("AUTHORIZED");

// Find the token exchange call
const tokenCall = mockFetch.mock.calls.find(call =>
call[0].toString().includes("/token")
);
expect(tokenCall).toBeDefined();

const body = tokenCall![1].body as URLSearchParams;
expect(body.get("resource")).toBe("https://api.example.com/mcp-server");
expect(body.get("code")).toBe("au 6D4E th-code-123");
});

it("includes resource in token refresh", async () => {
// Mock successful metadata discovery and token refresh
mockFetch.mockImplementation((url) => {
const urlString = url.toString();

if (urlString.includes("/.well-known/oauth-authorization-server")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
});
} else if (urlString.includes("/token")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
access_token: "new-access123",
token_type: "Bearer",
expires_in: 3600,
}),
});
}

return Promise.resolve({ ok: false, status: 404 });
});

// Mock provider methods for token refresh
(mockProvider.clientInformation as jest.Mock).mockResolvedValue({
client_id: "test-client",
client_secret: "test-secret",
});
(mockProvider.tokens as jest.Mock).mockResolvedValue({
access_token: "old-access",
refresh_token: "refresh123",
});
(mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined);

// Call auth with existing tokens (should trigger refresh)
const result = await auth(mockProvider, {
serverUrl: "https://api.example.com/mcp-server",
});

expect(result).toBe("AUTHORIZED");

// Find the token refresh call
const tokenCall = mockFetch.mock.calls.find(call =>
call[0].toString().includes("/token")
);
expect(tokenCall).toBeDefined();

const body = tokenCall![1].body as URLSearchParams;
expect(body.get("resource")).toBe("https://api.example.com/mcp-server");
expect(body.get("grant_type")).toBe("refresh_token");
expect(body.get("refresh_token")).toBe("refresh123");
});

it("skips default PRM resource validation when custom validateResourceURL is provided", async () => {
const mockValidateResourceURL = jest.fn().mockResolvedValue(undefined);
const providerWithCustomValidation = {
...mockProvider,
validateResourceURL: mockValidateResourceURL,
};

// Mock protected resource metadata with mismatched resource URL
// This would normally throw an error in default validation, but should be skipped
mockFetch.mockImplementation((url) => {
const urlString = url.toString();

if (urlString.includes("/.well-known/oauth-protected-resource")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
resource: "https://different-resource.example.com/mcp-server", // Mismatched resource
authorization_servers: ["https://auth.example.com"],
}),
});
} else if (urlString.includes("/.well-known/oauth-authorization-server")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
}),
});
}

return Promise.resolve({ ok: false, status: 404 });
});

// Mock provider methods
(providerWithCustomValidation.clientInformation as jest.Mock).mockResolvedValue({
client_id: "test-client",
client_secret: "test-secret",
});
(providerWithCustomValidation.tokens as jest.Mock).mockResolvedValue(undefined);
(providerWithCustomValidation.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined);
(providerWithCustomValidation.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined);

// Call auth - should succeed despite resource mismatch because custom validation overrides default
const result = await auth(providerWithCustomValidation, {
serverUrl: "https://api.example.com/mcp-server",
});

expect(result).toBe("REDIRECT");

// Verify custom validation method was called
expect(mockValidateResourceURL).toHaveBeenCalledWith(
"https://api.example.com/mcp-server",
"https://different-resource.example.com/mcp-server"
);
});
});
});
Loading
0