Merge branch 'main' into fix/1
This commit is contained in:
commit
bd61d2149a
45 changed files with 6238 additions and 602 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import { LATEST_PROTOCOL_VERSION } from '../types.js';
|
||||
import {
|
||||
discoverOAuthMetadata,
|
||||
startAuthorization,
|
||||
|
|
@ -202,7 +203,145 @@ describe("OAuth Authorization", () => {
|
|||
const [url, options] = calls[0];
|
||||
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
|
||||
expect(options.headers).toEqual({
|
||||
"MCP-Protocol-Version": "2025-03-26"
|
||||
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
|
||||
});
|
||||
});
|
||||
|
||||
it("returns metadata when discovery succeeds with path", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validMetadata,
|
||||
});
|
||||
|
||||
const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name");
|
||||
expect(metadata).toEqual(validMetadata);
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(1);
|
||||
const [url, options] = calls[0];
|
||||
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name");
|
||||
expect(options.headers).toEqual({
|
||||
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to root discovery when path-aware discovery returns 404", async () => {
|
||||
// First call (path-aware) returns 404
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
// Second call (root fallback) succeeds
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validMetadata,
|
||||
});
|
||||
|
||||
const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name");
|
||||
expect(metadata).toEqual(validMetadata);
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(2);
|
||||
|
||||
// First call should be path-aware
|
||||
const [firstUrl, firstOptions] = calls[0];
|
||||
expect(firstUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name");
|
||||
expect(firstOptions.headers).toEqual({
|
||||
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
|
||||
});
|
||||
|
||||
// Second call should be root fallback
|
||||
const [secondUrl, secondOptions] = calls[1];
|
||||
expect(secondUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
|
||||
expect(secondOptions.headers).toEqual({
|
||||
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined when both path-aware and root discovery return 404", async () => {
|
||||
// First call (path-aware) returns 404
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
// Second call (root fallback) also returns 404
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name");
|
||||
expect(metadata).toBeUndefined();
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it("does not fallback when the original URL is already at root path", async () => {
|
||||
// First call (path-aware for root) returns 404
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
const metadata = await discoverOAuthMetadata("https://auth.example.com/");
|
||||
expect(metadata).toBeUndefined();
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(1); // Should not attempt fallback
|
||||
|
||||
const [url] = calls[0];
|
||||
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
|
||||
});
|
||||
|
||||
it("does not fallback when the original URL has no path", async () => {
|
||||
// First call (path-aware for no path) returns 404
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
const metadata = await discoverOAuthMetadata("https://auth.example.com");
|
||||
expect(metadata).toBeUndefined();
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(1); // Should not attempt fallback
|
||||
|
||||
const [url] = calls[0];
|
||||
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
|
||||
});
|
||||
|
||||
it("falls back when path-aware discovery encounters CORS error", async () => {
|
||||
// First call (path-aware) fails with TypeError (CORS)
|
||||
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error")));
|
||||
|
||||
// Retry path-aware without headers (simulating CORS retry)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
// Second call (root fallback) succeeds
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validMetadata,
|
||||
});
|
||||
|
||||
const metadata = await discoverOAuthMetadata("https://auth.example.com/deep/path");
|
||||
expect(metadata).toEqual(validMetadata);
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(3);
|
||||
|
||||
// Final call should be root fallback
|
||||
const [lastUrl, lastOptions] = calls[2];
|
||||
expect(lastUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
|
||||
expect(lastOptions.headers).toEqual({
|
||||
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -324,6 +463,7 @@ describe("OAuth Authorization", () => {
|
|||
metadata: undefined,
|
||||
clientInformation: validClientInfo,
|
||||
redirectUrl: "http://localhost:3000/callback",
|
||||
resource: new URL("https://api.example.com/mcp-server"),
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -338,6 +478,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");
|
||||
});
|
||||
|
||||
|
|
@ -465,6 +606,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);
|
||||
|
|
@ -487,6 +629,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 () => {
|
||||
|
|
@ -554,6 +697,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);
|
||||
|
|
@ -574,6 +718,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 () => {
|
||||
|
|
@ -807,5 +952,516 @@ describe("OAuth Authorization", () => {
|
|||
"https://resource.example.com/.well-known/oauth-authorization-server"
|
||||
);
|
||||
});
|
||||
|
||||
it("passes resource parameter through authorization flow", async () => {
|
||||
// Mock successful metadata discovery - need to include protected resource metadata
|
||||
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://api.example.com/mcp-server",
|
||||
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 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 - need protected resource metadata
|
||||
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://api.example.com/mcp-server",
|
||||
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"],
|
||||
}),
|
||||
});
|
||||
} 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("auth-code-123");
|
||||
});
|
||||
|
||||
it("includes resource in token refresh", async () => {
|
||||
// Mock successful metadata discovery and token refresh - need protected resource metadata
|
||||
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://api.example.com/mcp-server",
|
||||
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"],
|
||||
}),
|
||||
});
|
||||
} 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(
|
||||
new URL("https://api.example.com/mcp-server"),
|
||||
"https://different-resource.example.com/mcp-server"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses prefix of server URL from PRM resource as resource parameter", async () => {
|
||||
// Mock successful metadata discovery with resource URL that is a prefix of requested URL
|
||||
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 is a prefix of the requested server URL
|
||||
resource: "https://api.example.com/",
|
||||
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
|
||||
(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 with a URL that has the resource as prefix
|
||||
const result = await auth(mockProvider, {
|
||||
serverUrl: "https://api.example.com/mcp-server/endpoint",
|
||||
});
|
||||
|
||||
expect(result).toBe("REDIRECT");
|
||||
|
||||
// Verify the authorization URL includes the resource parameter from PRM
|
||||
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];
|
||||
// Should use the PRM's resource value, not the full requested URL
|
||||
expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/");
|
||||
});
|
||||
|
||||
it("excludes resource parameter when Protected Resource Metadata is not present", async () => {
|
||||
// Mock metadata discovery where protected resource metadata is not available (404)
|
||||
// but authorization server metadata is available
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlString = url.toString();
|
||||
|
||||
if (urlString.includes("/.well-known/oauth-protected-resource")) {
|
||||
// Protected resource metadata not available
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
} 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
|
||||
(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 - should not include resource parameter
|
||||
const result = await auth(mockProvider, {
|
||||
serverUrl: "https://api.example.com/mcp-server",
|
||||
});
|
||||
|
||||
expect(result).toBe("REDIRECT");
|
||||
|
||||
// Verify the authorization URL does NOT include 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];
|
||||
// Resource parameter should not be present when PRM is not available
|
||||
expect(authUrl.searchParams.has("resource")).toBe(false);
|
||||
});
|
||||
|
||||
it("excludes resource parameter in token exchange when Protected Resource Metadata is not present", async () => {
|
||||
// Mock metadata discovery - no protected resource metadata, but auth server metadata available
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlString = url.toString();
|
||||
|
||||
if (urlString.includes("/.well-known/oauth-protected-resource")) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
} 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"],
|
||||
}),
|
||||
});
|
||||
} 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;
|
||||
// Resource parameter should not be present when PRM is not available
|
||||
expect(body.has("resource")).toBe(false);
|
||||
expect(body.get("code")).toBe("auth-code-123");
|
||||
});
|
||||
|
||||
it("excludes resource parameter in token refresh when Protected Resource Metadata is not present", async () => {
|
||||
// Mock metadata discovery - no protected resource metadata, but auth server metadata available
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlString = url.toString();
|
||||
|
||||
if (urlString.includes("/.well-known/oauth-protected-resource")) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
} 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"],
|
||||
}),
|
||||
});
|
||||
} 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;
|
||||
// Resource parameter should not be present when PRM is not available
|
||||
expect(body.has("resource")).toBe(false);
|
||||
expect(body.get("grant_type")).toBe("refresh_token");
|
||||
expect(body.get("refresh_token")).toBe("refresh123");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import pkceChallenge from "pkce-challenge";
|
|||
import { LATEST_PROTOCOL_VERSION } from "../types.js";
|
||||
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js";
|
||||
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
|
||||
import { checkResourceAllowed, resourceUrlFromServerUrl } from "../shared/auth-utils.js";
|
||||
|
||||
/**
|
||||
* Implements an end-to-end OAuth client to be used with one MCP server.
|
||||
|
|
@ -71,6 +72,15 @@ export interface OAuthClientProvider {
|
|||
* the authorization result.
|
||||
*/
|
||||
codeVerifier(): string | Promise<string>;
|
||||
|
||||
/**
|
||||
* If defined, overrides the selection and validation of the
|
||||
* RFC 8707 Resource Indicator. If left undefined, default
|
||||
* validation behavior will be used.
|
||||
*
|
||||
* Implementations must verify the returned resource matches the MCP server.
|
||||
*/
|
||||
validateResourceURL?(serverUrl: string | URL, resource?: string): Promise<URL | undefined>;
|
||||
}
|
||||
|
||||
export type AuthResult = "AUTHORIZED" | "REDIRECT";
|
||||
|
|
@ -99,18 +109,19 @@ export async function auth(
|
|||
scope?: string;
|
||||
resourceMetadataUrl?: URL }): Promise<AuthResult> {
|
||||
|
||||
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
|
||||
let authorizationServerUrl = serverUrl;
|
||||
try {
|
||||
const resourceMetadata = await discoverOAuthProtectedResourceMetadata(
|
||||
resourceMetadataUrl || serverUrl);
|
||||
|
||||
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {resourceMetadataUrl});
|
||||
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
|
||||
authorizationServerUrl = resourceMetadata.authorization_servers[0];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error)
|
||||
} catch {
|
||||
// Ignore errors and fall back to /.well-known/oauth-authorization-server
|
||||
}
|
||||
|
||||
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
|
||||
|
||||
const metadata = await discoverOAuthMetadata(authorizationServerUrl);
|
||||
|
||||
// Handle client registration if needed
|
||||
|
|
@ -142,6 +153,7 @@ export async function auth(
|
|||
authorizationCode,
|
||||
codeVerifier,
|
||||
redirectUri: provider.redirectUrl,
|
||||
resource,
|
||||
});
|
||||
|
||||
await provider.saveTokens(tokens);
|
||||
|
|
@ -158,12 +170,13 @@ export async function auth(
|
|||
metadata,
|
||||
clientInformation,
|
||||
refreshToken: tokens.refresh_token,
|
||||
resource,
|
||||
});
|
||||
|
||||
await provider.saveTokens(newTokens);
|
||||
return "AUTHORIZED";
|
||||
} catch (error) {
|
||||
console.error("Could not refresh OAuth tokens:", error);
|
||||
} catch {
|
||||
// Could not refresh OAuth tokens
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,6 +189,7 @@ export async function auth(
|
|||
state,
|
||||
redirectUrl: provider.redirectUrl,
|
||||
scope: scope || provider.clientMetadata.scope,
|
||||
resource,
|
||||
});
|
||||
|
||||
await provider.saveCodeVerifier(codeVerifier);
|
||||
|
|
@ -183,6 +197,27 @@ export async function auth(
|
|||
return "REDIRECT";
|
||||
}
|
||||
|
||||
export async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise<URL | undefined> {
|
||||
const defaultResource = resourceUrlFromServerUrl(serverUrl);
|
||||
|
||||
// If provider has custom validation, delegate to it
|
||||
if (provider.validateResourceURL) {
|
||||
return await provider.validateResourceURL(defaultResource, resourceMetadata?.resource);
|
||||
}
|
||||
|
||||
// Only include resource parameter when Protected Resource Metadata is present
|
||||
if (!resourceMetadata) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Validate that the metadata's resource is compatible with our request
|
||||
if (!checkResourceAllowed({ requestedResource: defaultResource, configuredResource: resourceMetadata.resource })) {
|
||||
throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`);
|
||||
}
|
||||
// Prefer the resource from metadata since it's what the server is telling us to request
|
||||
return new URL(resourceMetadata.resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract resource_metadata from response header.
|
||||
*/
|
||||
|
|
@ -195,7 +230,6 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined {
|
|||
|
||||
const [type, scheme] = authenticateHeader.split(' ');
|
||||
if (type.toLowerCase() !== 'bearer' || !scheme) {
|
||||
console.log("Invalid WWW-Authenticate header format, expected 'Bearer'");
|
||||
return undefined;
|
||||
}
|
||||
const regex = /resource_metadata="([^"]*)"/;
|
||||
|
|
@ -208,7 +242,6 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined {
|
|||
try {
|
||||
return new URL(match[1]);
|
||||
} catch {
|
||||
console.log("Invalid resource metadata url: ", match[1]);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -265,28 +298,82 @@ export async function discoverOAuthProtectedResourceMetadata(
|
|||
* If the server returns a 404 for the well-known endpoint, this function will
|
||||
* return `undefined`. Any other errors will be thrown as exceptions.
|
||||
*/
|
||||
/**
|
||||
* Helper function to handle fetch with CORS retry logic
|
||||
*/
|
||||
async function fetchWithCorsRetry(
|
||||
url: URL,
|
||||
headers: Record<string, string>,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
return await fetch(url, { headers });
|
||||
} catch (error) {
|
||||
// CORS errors come back as TypeError, retry without headers
|
||||
if (error instanceof TypeError) {
|
||||
return await fetch(url);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the well-known path for OAuth metadata discovery
|
||||
*/
|
||||
function buildWellKnownPath(pathname: string): string {
|
||||
let wellKnownPath = `/.well-known/oauth-authorization-server${pathname}`;
|
||||
if (pathname.endsWith('/')) {
|
||||
// Strip trailing slash from pathname to avoid double slashes
|
||||
wellKnownPath = wellKnownPath.slice(0, -1);
|
||||
}
|
||||
return wellKnownPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to discover OAuth metadata at a specific URL
|
||||
*/
|
||||
async function tryMetadataDiscovery(
|
||||
url: URL,
|
||||
protocolVersion: string,
|
||||
): Promise<Response> {
|
||||
const headers = {
|
||||
"MCP-Protocol-Version": protocolVersion
|
||||
};
|
||||
return await fetchWithCorsRetry(url, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if fallback to root discovery should be attempted
|
||||
*/
|
||||
function shouldAttemptFallback(response: Response, pathname: string): boolean {
|
||||
return response.status === 404 && pathname !== '/';
|
||||
}
|
||||
|
||||
export async function discoverOAuthMetadata(
|
||||
authorizationServerUrl: string | URL,
|
||||
opts?: { protocolVersion?: string },
|
||||
): Promise<OAuthMetadata | undefined> {
|
||||
const url = new URL("/.well-known/oauth-authorization-server", authorizationServerUrl);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: {
|
||||
"MCP-Protocol-Version": opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// CORS errors come back as TypeError
|
||||
if (error instanceof TypeError) {
|
||||
response = await fetch(url);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const issuer = new URL(authorizationServerUrl);
|
||||
const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION;
|
||||
|
||||
if (response.status === 404) {
|
||||
// Try path-aware discovery first (RFC 8414 compliant)
|
||||
const wellKnownPath = buildWellKnownPath(issuer.pathname);
|
||||
const pathAwareUrl = new URL(wellKnownPath, issuer);
|
||||
let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion);
|
||||
|
||||
// If path-aware discovery fails with 404, try fallback to root discovery
|
||||
if (shouldAttemptFallback(response, issuer.pathname)) {
|
||||
try {
|
||||
const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer);
|
||||
response = await tryMetadataDiscovery(rootUrl, protocolVersion);
|
||||
|
||||
if (response.status === 404) {
|
||||
return undefined;
|
||||
}
|
||||
} catch {
|
||||
// If fallback fails, return undefined
|
||||
return undefined;
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
@ -310,12 +397,14 @@ export async function startAuthorization(
|
|||
redirectUrl,
|
||||
scope,
|
||||
state,
|
||||
resource,
|
||||
}: {
|
||||
metadata?: OAuthMetadata;
|
||||
clientInformation: OAuthClientInformation;
|
||||
redirectUrl: string | URL;
|
||||
scope?: string;
|
||||
state?: string;
|
||||
resource?: URL;
|
||||
},
|
||||
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
|
||||
const responseType = "code";
|
||||
|
|
@ -365,6 +454,10 @@ export async function startAuthorization(
|
|||
authorizationUrl.searchParams.set("scope", scope);
|
||||
}
|
||||
|
||||
if (resource) {
|
||||
authorizationUrl.searchParams.set("resource", resource.href);
|
||||
}
|
||||
|
||||
return { authorizationUrl, codeVerifier };
|
||||
}
|
||||
|
||||
|
|
@ -379,12 +472,14 @@ export async function exchangeAuthorization(
|
|||
authorizationCode,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
resource,
|
||||
}: {
|
||||
metadata?: OAuthMetadata;
|
||||
clientInformation: OAuthClientInformation;
|
||||
authorizationCode: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string | URL;
|
||||
resource?: URL;
|
||||
},
|
||||
): Promise<OAuthTokens> {
|
||||
const grantType = "authorization_code";
|
||||
|
|
@ -418,6 +513,10 @@ export async function exchangeAuthorization(
|
|||
params.set("client_secret", clientInformation.client_secret);
|
||||
}
|
||||
|
||||
if (resource) {
|
||||
params.set("resource", resource.href);
|
||||
}
|
||||
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -442,10 +541,12 @@ export async function refreshAuthorization(
|
|||
metadata,
|
||||
clientInformation,
|
||||
refreshToken,
|
||||
resource,
|
||||
}: {
|
||||
metadata?: OAuthMetadata;
|
||||
clientInformation: OAuthClientInformation;
|
||||
refreshToken: string;
|
||||
resource?: URL;
|
||||
},
|
||||
): Promise<OAuthTokens> {
|
||||
const grantType = "refresh_token";
|
||||
|
|
@ -477,6 +578,10 @@ export async function refreshAuthorization(
|
|||
params.set("client_secret", clientInformation.client_secret);
|
||||
}
|
||||
|
||||
if (resource) {
|
||||
params.set("resource", resource.href);
|
||||
}
|
||||
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
CreateMessageRequestSchema,
|
||||
ElicitRequestSchema,
|
||||
ListRootsRequestSchema,
|
||||
ErrorCode,
|
||||
} from "../types.js";
|
||||
|
|
@ -597,6 +598,43 @@ test("should only allow setRequestHandler for declared capabilities", () => {
|
|||
}).toThrow("Client does not support roots capability");
|
||||
});
|
||||
|
||||
test("should allow setRequestHandler for declared elicitation capability", () => {
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test-client",
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// This should work because elicitation is a declared capability
|
||||
expect(() => {
|
||||
client.setRequestHandler(ElicitRequestSchema, () => ({
|
||||
action: "accept",
|
||||
content: {
|
||||
username: "test-user",
|
||||
confirmed: true,
|
||||
},
|
||||
}));
|
||||
}).not.toThrow();
|
||||
|
||||
// This should throw because sampling is not a declared capability
|
||||
expect(() => {
|
||||
client.setRequestHandler(CreateMessageRequestSchema, () => ({
|
||||
model: "test-model",
|
||||
role: "assistant",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "Test response",
|
||||
},
|
||||
}));
|
||||
}).toThrow("Client does not support sampling capability");
|
||||
});
|
||||
|
||||
/***
|
||||
* Test: Type Checking
|
||||
* Test that custom request/notification/result schemas can be used with the Client class.
|
||||
|
|
|
|||
|
|
@ -165,6 +165,10 @@ export class Client<
|
|||
|
||||
this._serverCapabilities = result.capabilities;
|
||||
this._serverVersion = result.serverInfo;
|
||||
// HTTP transports must set the protocol version in each header after initialization.
|
||||
if (transport.setProtocolVersion) {
|
||||
transport.setProtocolVersion(result.protocolVersion);
|
||||
}
|
||||
|
||||
this._instructions = result.instructions;
|
||||
|
||||
|
|
@ -303,6 +307,14 @@ export class Client<
|
|||
}
|
||||
break;
|
||||
|
||||
case "elicitation/create":
|
||||
if (!this._capabilities.elicitation) {
|
||||
throw new Error(
|
||||
`Client does not support elicitation capability (required for ${method})`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "roots/list":
|
||||
if (!this._capabilities.roots) {
|
||||
throw new Error(
|
||||
|
|
@ -474,8 +486,8 @@ export class Client<
|
|||
try {
|
||||
const validator = this._ajv.compile(tool.outputSchema);
|
||||
this._cachedToolOutputValidators.set(tool.name, validator);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to compile output schema for tool ${tool.name}: ${error}`);
|
||||
} catch {
|
||||
// Ignore schema compilation errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -398,7 +398,7 @@ describe("SSEClientTransport", () => {
|
|||
'Content-Type': 'application/json',
|
||||
})
|
||||
.end(JSON.stringify({
|
||||
resource: "https://resource.example.com",
|
||||
resource: resourceBaseUrl.href,
|
||||
authorization_servers: [`${authBaseUrl}`],
|
||||
}));
|
||||
return;
|
||||
|
|
@ -450,7 +450,7 @@ describe("SSEClientTransport", () => {
|
|||
'Content-Type': 'application/json',
|
||||
})
|
||||
.end(JSON.stringify({
|
||||
resource: "https://resource.example.com",
|
||||
resource: resourceBaseUrl.href,
|
||||
authorization_servers: [`${authBaseUrl}`],
|
||||
}));
|
||||
return;
|
||||
|
|
@ -601,7 +601,7 @@ describe("SSEClientTransport", () => {
|
|||
'Content-Type': 'application/json',
|
||||
})
|
||||
.end(JSON.stringify({
|
||||
resource: "https://resource.example.com",
|
||||
resource: resourceBaseUrl.href,
|
||||
authorization_servers: [`${authBaseUrl}`],
|
||||
}));
|
||||
return;
|
||||
|
|
@ -723,7 +723,7 @@ describe("SSEClientTransport", () => {
|
|||
'Content-Type': 'application/json',
|
||||
})
|
||||
.end(JSON.stringify({
|
||||
resource: "https://resource.example.com",
|
||||
resource: resourceBaseUrl.href,
|
||||
authorization_servers: [`${authBaseUrl}`],
|
||||
}));
|
||||
return;
|
||||
|
|
@ -851,7 +851,7 @@ describe("SSEClientTransport", () => {
|
|||
'Content-Type': 'application/json',
|
||||
})
|
||||
.end(JSON.stringify({
|
||||
resource: "https://resource.example.com",
|
||||
resource: resourceBaseUrl.href,
|
||||
authorization_servers: [`${authBaseUrl}`],
|
||||
}));
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export class SSEClientTransport implements Transport {
|
|||
private _eventSourceInit?: EventSourceInit;
|
||||
private _requestInit?: RequestInit;
|
||||
private _authProvider?: OAuthClientProvider;
|
||||
private _protocolVersion?: string;
|
||||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
|
|
@ -99,35 +100,52 @@ export class SSEClientTransport implements Transport {
|
|||
}
|
||||
|
||||
private async _commonHeaders(): Promise<HeadersInit> {
|
||||
const headers: HeadersInit = {};
|
||||
const headers = {
|
||||
...this._requestInit?.headers,
|
||||
} as HeadersInit & Record<string, string>;
|
||||
if (this._authProvider) {
|
||||
const tokens = await this._authProvider.tokens();
|
||||
if (tokens) {
|
||||
headers["Authorization"] = `Bearer ${tokens.access_token}`;
|
||||
}
|
||||
}
|
||||
if (this._protocolVersion) {
|
||||
headers["mcp-protocol-version"] = this._protocolVersion;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private _startOrAuth(): Promise<void> {
|
||||
const fetchImpl = (this?._eventSourceInit?.fetch || fetch) as typeof fetch
|
||||
return new Promise((resolve, reject) => {
|
||||
this._eventSource = new EventSource(
|
||||
this._url.href,
|
||||
this._eventSourceInit ?? {
|
||||
fetch: (url, init) => this._commonHeaders().then((headers) => fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...headers,
|
||||
Accept: "text/event-stream"
|
||||
{
|
||||
...this._eventSourceInit,
|
||||
fetch: async (url, init) => {
|
||||
const headers = await this._commonHeaders()
|
||||
const response = await fetchImpl(url, {
|
||||
...init,
|
||||
headers: new Headers({
|
||||
...headers,
|
||||
Accept: "text/event-stream"
|
||||
})
|
||||
})
|
||||
|
||||
if (response.status === 401 && response.headers.has('www-authenticate')) {
|
||||
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
|
||||
}
|
||||
})),
|
||||
|
||||
return response
|
||||
},
|
||||
},
|
||||
);
|
||||
this._abortController = new AbortController();
|
||||
|
||||
this._eventSource.onerror = (event) => {
|
||||
if (event.code === 401 && this._authProvider) {
|
||||
|
||||
this._authThenStart().then(resolve, reject);
|
||||
return;
|
||||
}
|
||||
|
|
@ -214,7 +232,7 @@ export class SSEClientTransport implements Transport {
|
|||
|
||||
try {
|
||||
const commonHeaders = await this._commonHeaders();
|
||||
const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers });
|
||||
const headers = new Headers(commonHeaders);
|
||||
headers.set("content-type", "application/json");
|
||||
const init = {
|
||||
...this._requestInit,
|
||||
|
|
@ -249,4 +267,8 @@ export class SSEClientTransport implements Transport {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
setProtocolVersion(version: string): void {
|
||||
this._protocolVersion = version;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export const DEFAULT_INHERITED_ENV_VARS =
|
|||
"TEMP",
|
||||
"USERNAME",
|
||||
"USERPROFILE",
|
||||
"PROGRAMFILES",
|
||||
]
|
||||
: /* list inspired by the default env inheritance of sudo */
|
||||
["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"];
|
||||
|
|
|
|||
|
|
@ -476,6 +476,37 @@ describe("StreamableHTTPClientTransport", () => {
|
|||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should always send specified custom headers (Headers class)", async () => {
|
||||
const requestInit = {
|
||||
headers: new Headers({
|
||||
"X-Custom-Header": "CustomValue"
|
||||
})
|
||||
};
|
||||
transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), {
|
||||
requestInit: requestInit
|
||||
});
|
||||
|
||||
let actualReqInit: RequestInit = {};
|
||||
|
||||
((global.fetch as jest.Mock)).mockImplementation(
|
||||
async (_url, reqInit) => {
|
||||
actualReqInit = reqInit;
|
||||
return new Response(null, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
);
|
||||
|
||||
await transport.start();
|
||||
|
||||
await transport["_startOrAuthSse"]({});
|
||||
expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("CustomValue");
|
||||
|
||||
(requestInit.headers as Headers).set("X-Custom-Header","SecondCustomValue");
|
||||
|
||||
await transport.send({ jsonrpc: "2.0", method: "test", params: {} } as JSONRPCMessage);
|
||||
expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("SecondCustomValue");
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should have exponential backoff with configurable maxRetries", () => {
|
||||
// This test verifies the maxRetries and backoff calculation directly
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
private _authProvider?: OAuthClientProvider;
|
||||
private _sessionId?: string;
|
||||
private _reconnectionOptions: StreamableHTTPReconnectionOptions;
|
||||
private _protocolVersion?: string;
|
||||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
|
|
@ -162,7 +163,7 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
}
|
||||
|
||||
private async _commonHeaders(): Promise<Headers> {
|
||||
const headers: HeadersInit = {};
|
||||
const headers: HeadersInit & Record<string, string> = {};
|
||||
if (this._authProvider) {
|
||||
const tokens = await this._authProvider.tokens();
|
||||
if (tokens) {
|
||||
|
|
@ -173,10 +174,16 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
if (this._sessionId) {
|
||||
headers["mcp-session-id"] = this._sessionId;
|
||||
}
|
||||
if (this._protocolVersion) {
|
||||
headers["mcp-protocol-version"] = this._protocolVersion;
|
||||
}
|
||||
|
||||
return new Headers(
|
||||
{ ...headers, ...this._requestInit?.headers }
|
||||
);
|
||||
const extraHeaders = this._normalizeHeaders(this._requestInit?.headers);
|
||||
|
||||
return new Headers({
|
||||
...headers,
|
||||
...extraHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -242,6 +249,20 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
|
||||
}
|
||||
|
||||
private _normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
if (!headers) return {};
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
return Object.fromEntries(headers.entries());
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
return Object.fromEntries(headers);
|
||||
}
|
||||
|
||||
return { ...headers as Record<string, string> };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a reconnection attempt with exponential backoff
|
||||
*
|
||||
|
|
@ -516,4 +537,11 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
setProtocolVersion(version: string): void {
|
||||
this._protocolVersion = version;
|
||||
}
|
||||
get protocolVersion(): string | undefined {
|
||||
return this._protocolVersion;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ npx tsx src/examples/server/simpleStreamableHttp.ts
|
|||
|
||||
# To add a demo of authentication to this example, use:
|
||||
npx tsx src/examples/server/simpleStreamableHttp.ts --oauth
|
||||
|
||||
# To mitigate impersonation risks, enable strict Resource Identifier verification:
|
||||
npx tsx src/examples/server/simpleStreamableHttp.ts --oauth --oauth-strict
|
||||
```
|
||||
|
||||
##### JSON Response Mode Server
|
||||
|
|
|
|||
|
|
@ -14,7 +14,13 @@ import {
|
|||
ListResourcesResultSchema,
|
||||
LoggingMessageNotificationSchema,
|
||||
ResourceListChangedNotificationSchema,
|
||||
ElicitRequestSchema,
|
||||
ResourceLink,
|
||||
ReadResourceRequest,
|
||||
ReadResourceResultSchema,
|
||||
} from '../../types.js';
|
||||
import { getDisplayName } from '../../shared/metadataUtils.js';
|
||||
import Ajv from "ajv";
|
||||
|
||||
// Create readline interface for user input
|
||||
const readline = createInterface({
|
||||
|
|
@ -54,11 +60,13 @@ function printHelp(): void {
|
|||
console.log(' call-tool <name> [args] - Call a tool with optional JSON arguments');
|
||||
console.log(' greet [name] - Call the greet tool');
|
||||
console.log(' multi-greet [name] - Call the multi-greet tool with notifications');
|
||||
console.log(' collect-info [type] - Test elicitation with collect-user-info tool (contact/preferences/feedback)');
|
||||
console.log(' start-notifications [interval] [count] - Start periodic notifications');
|
||||
console.log(' run-notifications-tool-with-resumability [interval] [count] - Run notification tool with resumability');
|
||||
console.log(' list-prompts - List available prompts');
|
||||
console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments');
|
||||
console.log(' list-resources - List available resources');
|
||||
console.log(' read-resource <uri> - Read a specific resource by URI');
|
||||
console.log(' help - Show this help');
|
||||
console.log(' quit - Exit the program');
|
||||
}
|
||||
|
|
@ -115,6 +123,10 @@ function commandLoop(): void {
|
|||
await callMultiGreetTool(args[1] || 'MCP User');
|
||||
break;
|
||||
|
||||
case 'collect-info':
|
||||
await callCollectInfoTool(args[1] || 'contact');
|
||||
break;
|
||||
|
||||
case 'start-notifications': {
|
||||
const interval = args[1] ? parseInt(args[1], 10) : 2000;
|
||||
const count = args[2] ? parseInt(args[2], 10) : 10;
|
||||
|
|
@ -154,6 +166,14 @@ function commandLoop(): void {
|
|||
await listResources();
|
||||
break;
|
||||
|
||||
case 'read-resource':
|
||||
if (args.length < 2) {
|
||||
console.log('Usage: read-resource <uri>');
|
||||
} else {
|
||||
await readResource(args[1]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
printHelp();
|
||||
break;
|
||||
|
|
@ -191,15 +211,212 @@ async function connect(url?: string): Promise<void> {
|
|||
console.log(`Connecting to ${serverUrl}...`);
|
||||
|
||||
try {
|
||||
// Create a new client
|
||||
// Create a new client with elicitation capability
|
||||
client = new Client({
|
||||
name: 'example-client',
|
||||
version: '1.0.0'
|
||||
}, {
|
||||
capabilities: {
|
||||
elicitation: {},
|
||||
},
|
||||
});
|
||||
client.onerror = (error) => {
|
||||
console.error('\x1b[31mClient error:', error, '\x1b[0m');
|
||||
}
|
||||
|
||||
// Set up elicitation request handler with proper validation
|
||||
client.setRequestHandler(ElicitRequestSchema, async (request) => {
|
||||
console.log('\n🔔 Elicitation Request Received:');
|
||||
console.log(`Message: ${request.params.message}`);
|
||||
console.log('Requested Schema:');
|
||||
console.log(JSON.stringify(request.params.requestedSchema, null, 2));
|
||||
|
||||
const schema = request.params.requestedSchema;
|
||||
const properties = schema.properties;
|
||||
const required = schema.required || [];
|
||||
|
||||
// Set up AJV validator for the requested schema
|
||||
const ajv = new Ajv();
|
||||
const validate = ajv.compile(schema);
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 3;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
attempts++;
|
||||
console.log(`\nPlease provide the following information (attempt ${attempts}/${maxAttempts}):`);
|
||||
|
||||
const content: Record<string, unknown> = {};
|
||||
let inputCancelled = false;
|
||||
|
||||
// Collect input for each field
|
||||
for (const [fieldName, fieldSchema] of Object.entries(properties)) {
|
||||
const field = fieldSchema as {
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
enum?: string[];
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
format?: string;
|
||||
};
|
||||
|
||||
const isRequired = required.includes(fieldName);
|
||||
let prompt = `${field.title || fieldName}`;
|
||||
|
||||
// Add helpful information to the prompt
|
||||
if (field.description) {
|
||||
prompt += ` (${field.description})`;
|
||||
}
|
||||
if (field.enum) {
|
||||
prompt += ` [options: ${field.enum.join(', ')}]`;
|
||||
}
|
||||
if (field.type === 'number' || field.type === 'integer') {
|
||||
if (field.minimum !== undefined && field.maximum !== undefined) {
|
||||
prompt += ` [${field.minimum}-${field.maximum}]`;
|
||||
} else if (field.minimum !== undefined) {
|
||||
prompt += ` [min: ${field.minimum}]`;
|
||||
} else if (field.maximum !== undefined) {
|
||||
prompt += ` [max: ${field.maximum}]`;
|
||||
}
|
||||
}
|
||||
if (field.type === 'string' && field.format) {
|
||||
prompt += ` [format: ${field.format}]`;
|
||||
}
|
||||
if (isRequired) {
|
||||
prompt += ' *required*';
|
||||
}
|
||||
if (field.default !== undefined) {
|
||||
prompt += ` [default: ${field.default}]`;
|
||||
}
|
||||
|
||||
prompt += ': ';
|
||||
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
readline.question(prompt, (input) => {
|
||||
resolve(input.trim());
|
||||
});
|
||||
});
|
||||
|
||||
// Check for cancellation
|
||||
if (answer.toLowerCase() === 'cancel' || answer.toLowerCase() === 'c') {
|
||||
inputCancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Parse and validate the input
|
||||
try {
|
||||
if (answer === '' && field.default !== undefined) {
|
||||
content[fieldName] = field.default;
|
||||
} else if (answer === '' && !isRequired) {
|
||||
// Skip optional empty fields
|
||||
continue;
|
||||
} else if (answer === '') {
|
||||
throw new Error(`${fieldName} is required`);
|
||||
} else {
|
||||
// Parse the value based on type
|
||||
let parsedValue: unknown;
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
parsedValue = answer.toLowerCase() === 'true' || answer.toLowerCase() === 'yes' || answer === '1';
|
||||
} else if (field.type === 'number') {
|
||||
parsedValue = parseFloat(answer);
|
||||
if (isNaN(parsedValue as number)) {
|
||||
throw new Error(`${fieldName} must be a valid number`);
|
||||
}
|
||||
} else if (field.type === 'integer') {
|
||||
parsedValue = parseInt(answer, 10);
|
||||
if (isNaN(parsedValue as number)) {
|
||||
throw new Error(`${fieldName} must be a valid integer`);
|
||||
}
|
||||
} else if (field.enum) {
|
||||
if (!field.enum.includes(answer)) {
|
||||
throw new Error(`${fieldName} must be one of: ${field.enum.join(', ')}`);
|
||||
}
|
||||
parsedValue = answer;
|
||||
} else {
|
||||
parsedValue = answer;
|
||||
}
|
||||
|
||||
content[fieldName] = parsedValue;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ Error: ${error}`);
|
||||
// Continue to next attempt
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (inputCancelled) {
|
||||
return { action: 'cancel' };
|
||||
}
|
||||
|
||||
// If we didn't complete all fields due to an error, try again
|
||||
if (Object.keys(content).length !== Object.keys(properties).filter(name =>
|
||||
required.includes(name) || content[name] !== undefined
|
||||
).length) {
|
||||
if (attempts < maxAttempts) {
|
||||
console.log('Please try again...');
|
||||
continue;
|
||||
} else {
|
||||
console.log('Maximum attempts reached. Declining request.');
|
||||
return { action: 'reject' };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the complete object against the schema
|
||||
const isValid = validate(content);
|
||||
|
||||
if (!isValid) {
|
||||
console.log('❌ Validation errors:');
|
||||
validate.errors?.forEach(error => {
|
||||
console.log(` - ${error.dataPath || 'root'}: ${error.message}`);
|
||||
});
|
||||
|
||||
if (attempts < maxAttempts) {
|
||||
console.log('Please correct the errors and try again...');
|
||||
continue;
|
||||
} else {
|
||||
console.log('Maximum attempts reached. Declining request.');
|
||||
return { action: 'reject' };
|
||||
}
|
||||
}
|
||||
|
||||
// Show the collected data and ask for confirmation
|
||||
console.log('\n✅ Collected data:');
|
||||
console.log(JSON.stringify(content, null, 2));
|
||||
|
||||
const confirmAnswer = await new Promise<string>((resolve) => {
|
||||
readline.question('\nSubmit this information? (yes/no/cancel): ', (input) => {
|
||||
resolve(input.trim().toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
if (confirmAnswer === 'yes' || confirmAnswer === 'y') {
|
||||
return {
|
||||
action: 'accept',
|
||||
content,
|
||||
};
|
||||
} else if (confirmAnswer === 'cancel' || confirmAnswer === 'c') {
|
||||
return { action: 'cancel' };
|
||||
} else if (confirmAnswer === 'no' || confirmAnswer === 'n') {
|
||||
if (attempts < maxAttempts) {
|
||||
console.log('Please re-enter the information...');
|
||||
continue;
|
||||
} else {
|
||||
return { action: 'reject' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Maximum attempts reached. Declining request.');
|
||||
return { action: 'reject' };
|
||||
});
|
||||
|
||||
transport = new StreamableHTTPClientTransport(
|
||||
new URL(serverUrl),
|
||||
{
|
||||
|
|
@ -317,7 +534,7 @@ async function listTools(): Promise<void> {
|
|||
console.log(' No tools available');
|
||||
} else {
|
||||
for (const tool of toolsResult.tools) {
|
||||
console.log(` - ${tool.name}: ${tool.description}`);
|
||||
console.log(` - id: ${tool.name}, name: ${getDisplayName(tool)}, description: ${tool.description}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -344,13 +561,37 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<vo
|
|||
const result = await client.request(request, CallToolResultSchema);
|
||||
|
||||
console.log('Tool result:');
|
||||
const resourceLinks: ResourceLink[] = [];
|
||||
|
||||
result.content.forEach(item => {
|
||||
if (item.type === 'text') {
|
||||
console.log(` ${item.text}`);
|
||||
} else if (item.type === 'resource_link') {
|
||||
const resourceLink = item as ResourceLink;
|
||||
resourceLinks.push(resourceLink);
|
||||
console.log(` 📁 Resource Link: ${resourceLink.name}`);
|
||||
console.log(` URI: ${resourceLink.uri}`);
|
||||
if (resourceLink.mimeType) {
|
||||
console.log(` Type: ${resourceLink.mimeType}`);
|
||||
}
|
||||
if (resourceLink.description) {
|
||||
console.log(` Description: ${resourceLink.description}`);
|
||||
}
|
||||
} else if (item.type === 'resource') {
|
||||
console.log(` [Embedded Resource: ${item.resource.uri}]`);
|
||||
} else if (item.type === 'image') {
|
||||
console.log(` [Image: ${item.mimeType}]`);
|
||||
} else if (item.type === 'audio') {
|
||||
console.log(` [Audio: ${item.mimeType}]`);
|
||||
} else {
|
||||
console.log(` ${item.type} content:`, item);
|
||||
console.log(` [Unknown content type]:`, item);
|
||||
}
|
||||
});
|
||||
|
||||
// Offer to read resource links
|
||||
if (resourceLinks.length > 0) {
|
||||
console.log(`\nFound ${resourceLinks.length} resource link(s). Use 'read-resource <uri>' to read their content.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error calling tool ${name}: ${error}`);
|
||||
}
|
||||
|
|
@ -366,6 +607,11 @@ async function callMultiGreetTool(name: string): Promise<void> {
|
|||
await callTool('multi-greet', { name });
|
||||
}
|
||||
|
||||
async function callCollectInfoTool(infoType: string): Promise<void> {
|
||||
console.log(`Testing elicitation with collect-user-info tool (${infoType})...`);
|
||||
await callTool('collect-user-info', { infoType });
|
||||
}
|
||||
|
||||
async function startNotifications(interval: number, count: number): Promise<void> {
|
||||
console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`);
|
||||
await callTool('start-notification-stream', { interval, count });
|
||||
|
|
@ -380,7 +626,7 @@ async function runNotificationsToolWithResumability(interval: number, count: num
|
|||
try {
|
||||
console.log(`Starting notification stream with resumability: interval=${interval}ms, count=${count || 'unlimited'}`);
|
||||
console.log(`Using resumption token: ${notificationsToolLastEventId || 'none'}`);
|
||||
|
||||
|
||||
const request: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
|
|
@ -393,7 +639,7 @@ async function runNotificationsToolWithResumability(interval: number, count: num
|
|||
notificationsToolLastEventId = event;
|
||||
console.log(`Updated resumption token: ${event}`);
|
||||
};
|
||||
|
||||
|
||||
const result = await client.request(request, CallToolResultSchema, {
|
||||
resumptionToken: notificationsToolLastEventId,
|
||||
onresumptiontoken: onLastEventIdUpdate
|
||||
|
|
@ -429,7 +675,7 @@ async function listPrompts(): Promise<void> {
|
|||
console.log(' No prompts available');
|
||||
} else {
|
||||
for (const prompt of promptsResult.prompts) {
|
||||
console.log(` - ${prompt.name}: ${prompt.description}`);
|
||||
console.log(` - id: ${prompt.name}, name: ${getDisplayName(prompt)}, description: ${prompt.description}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -480,7 +726,7 @@ async function listResources(): Promise<void> {
|
|||
console.log(' No resources available');
|
||||
} else {
|
||||
for (const resource of resourcesResult.resources) {
|
||||
console.log(` - ${resource.name}: ${resource.uri}`);
|
||||
console.log(` - id: ${resource.name}, name: ${getDisplayName(resource)}, description: ${resource.uri}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -488,6 +734,42 @@ async function listResources(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function readResource(uri: string): Promise<void> {
|
||||
if (!client) {
|
||||
console.log('Not connected to server.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request: ReadResourceRequest = {
|
||||
method: 'resources/read',
|
||||
params: { uri }
|
||||
};
|
||||
|
||||
console.log(`Reading resource: ${uri}`);
|
||||
const result = await client.request(request, ReadResourceResultSchema);
|
||||
|
||||
console.log('Resource contents:');
|
||||
for (const content of result.contents) {
|
||||
console.log(` URI: ${content.uri}`);
|
||||
if (content.mimeType) {
|
||||
console.log(` Type: ${content.mimeType}`);
|
||||
}
|
||||
|
||||
if ('text' in content && typeof content.text === 'string') {
|
||||
console.log(' Content:');
|
||||
console.log(' ---');
|
||||
console.log(content.text.split('\n').map((line: string) => ' ' + line).join('\n'));
|
||||
console.log(' ---');
|
||||
} else if ('blob' in content && typeof content.blob === 'string') {
|
||||
console.log(` [Binary data: ${content.blob.length} bytes]`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error reading resource ${uri}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
if (client && transport) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js';
|
||||
import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js';
|
||||
import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from 'src/shared/auth.js';
|
||||
import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '../../shared/auth.js';
|
||||
import express, { Request, Response } from "express";
|
||||
import { AuthInfo } from 'src/server/auth/types.js';
|
||||
import { createOAuthMetadata, mcpAuthRouter } from 'src/server/auth/router.js';
|
||||
import { AuthInfo } from '../../server/auth/types.js';
|
||||
import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js';
|
||||
import { resourceUrlFromServerUrl } from '../../shared/auth-utils.js';
|
||||
|
||||
|
||||
export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore {
|
||||
|
|
@ -35,6 +36,8 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider {
|
|||
client: OAuthClientInformationFull}>();
|
||||
private tokens = new Map<string, AuthInfo>();
|
||||
|
||||
constructor(private validateResource?: (resource?: URL) => boolean) {}
|
||||
|
||||
async authorize(
|
||||
client: OAuthClientInformationFull,
|
||||
params: AuthorizationParams,
|
||||
|
|
@ -89,6 +92,10 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider {
|
|||
throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`);
|
||||
}
|
||||
|
||||
if (this.validateResource && !this.validateResource(codeData.params.resource)) {
|
||||
throw new Error(`Invalid resource: ${codeData.params.resource}`);
|
||||
}
|
||||
|
||||
this.codes.delete(authorizationCode);
|
||||
const token = randomUUID();
|
||||
|
||||
|
|
@ -97,7 +104,8 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider {
|
|||
clientId: client.client_id,
|
||||
scopes: codeData.params.scopes || [],
|
||||
expiresAt: Date.now() + 3600000, // 1 hour
|
||||
type: 'access'
|
||||
resource: codeData.params.resource,
|
||||
type: 'access',
|
||||
};
|
||||
|
||||
this.tokens.set(token, tokenData);
|
||||
|
|
@ -113,7 +121,8 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider {
|
|||
async exchangeRefreshToken(
|
||||
_client: OAuthClientInformationFull,
|
||||
_refreshToken: string,
|
||||
_scopes?: string[]
|
||||
_scopes?: string[],
|
||||
_resource?: URL
|
||||
): Promise<OAuthTokens> {
|
||||
throw new Error('Not implemented for example demo');
|
||||
}
|
||||
|
|
@ -129,18 +138,26 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider {
|
|||
clientId: tokenData.clientId,
|
||||
scopes: tokenData.scopes,
|
||||
expiresAt: Math.floor(tokenData.expiresAt / 1000),
|
||||
resource: tokenData.resource,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => {
|
||||
export const setupAuthServer = ({authServerUrl, mcpServerUrl, strictResource}: {authServerUrl: URL, mcpServerUrl: URL, strictResource: boolean}): OAuthMetadata => {
|
||||
// Create separate auth server app
|
||||
// NOTE: This is a separate app on a separate port to illustrate
|
||||
// how to separate an OAuth Authorization Server from a Resource
|
||||
// server in the SDK. The SDK is not intended to be provide a standalone
|
||||
// authorization server.
|
||||
const provider = new DemoInMemoryAuthProvider();
|
||||
|
||||
const validateResource = strictResource ? (resource?: URL) => {
|
||||
if (!resource) return false;
|
||||
const expectedResource = resourceUrlFromServerUrl(mcpServerUrl);
|
||||
return resource.toString() === expectedResource.toString();
|
||||
} : undefined;
|
||||
|
||||
const provider = new DemoInMemoryAuthProvider(validateResource);
|
||||
const authApp = express();
|
||||
authApp.use(express.json());
|
||||
// For introspection requests
|
||||
|
|
@ -168,7 +185,8 @@ export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => {
|
|||
active: true,
|
||||
client_id: tokenInfo.clientId,
|
||||
scope: tokenInfo.scopes.join(' '),
|
||||
exp: tokenInfo.expiresAt
|
||||
exp: tokenInfo.expiresAt,
|
||||
aud: tokenInfo.resource,
|
||||
});
|
||||
return
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -5,27 +5,32 @@ import { McpServer } from '../../server/mcp.js';
|
|||
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
|
||||
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js';
|
||||
import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js';
|
||||
import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js';
|
||||
import { CallToolResult, GetPromptResult, isInitializeRequest, PrimitiveSchemaDefinition, ReadResourceResult, ResourceLink } from '../../types.js';
|
||||
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
|
||||
import { setupAuthServer } from './demoInMemoryOAuthProvider.js';
|
||||
import { OAuthMetadata } from 'src/shared/auth.js';
|
||||
import { checkResourceAllowed } from 'src/shared/auth-utils.js';
|
||||
|
||||
// Check for OAuth flag
|
||||
const useOAuth = process.argv.includes('--oauth');
|
||||
const strictOAuth = process.argv.includes('--oauth-strict');
|
||||
|
||||
// Create an MCP server with implementation details
|
||||
const getServer = () => {
|
||||
const server = new McpServer({
|
||||
name: 'simple-streamable-http-server',
|
||||
version: '1.0.0',
|
||||
version: '1.0.0'
|
||||
}, { capabilities: { logging: {} } });
|
||||
|
||||
// Register a simple tool that returns a greeting
|
||||
server.tool(
|
||||
server.registerTool(
|
||||
'greet',
|
||||
'A simple greeting tool',
|
||||
{
|
||||
name: z.string().describe('Name to greet'),
|
||||
title: 'Greeting Tool', // Display name for UI
|
||||
description: 'A simple greeting tool',
|
||||
inputSchema: {
|
||||
name: z.string().describe('Name to greet'),
|
||||
},
|
||||
},
|
||||
async ({ name }): Promise<CallToolResult> => {
|
||||
return {
|
||||
|
|
@ -83,13 +88,165 @@ const getServer = () => {
|
|||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Register a simple prompt
|
||||
server.prompt(
|
||||
'greeting-template',
|
||||
'A simple greeting prompt template',
|
||||
// Register a tool that demonstrates elicitation (user input collection)
|
||||
// This creates a closure that captures the server instance
|
||||
server.tool(
|
||||
'collect-user-info',
|
||||
'A tool that collects user information through elicitation',
|
||||
{
|
||||
name: z.string().describe('Name to include in greeting'),
|
||||
infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect'),
|
||||
},
|
||||
async ({ infoType }): Promise<CallToolResult> => {
|
||||
let message: string;
|
||||
let requestedSchema: {
|
||||
type: 'object';
|
||||
properties: Record<string, PrimitiveSchemaDefinition>;
|
||||
required?: string[];
|
||||
};
|
||||
|
||||
switch (infoType) {
|
||||
case 'contact':
|
||||
message = 'Please provide your contact information';
|
||||
requestedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: 'Full Name',
|
||||
description: 'Your full name',
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
title: 'Email Address',
|
||||
description: 'Your email address',
|
||||
format: 'email',
|
||||
},
|
||||
phone: {
|
||||
type: 'string',
|
||||
title: 'Phone Number',
|
||||
description: 'Your phone number (optional)',
|
||||
},
|
||||
},
|
||||
required: ['name', 'email'],
|
||||
};
|
||||
break;
|
||||
case 'preferences':
|
||||
message = 'Please set your preferences';
|
||||
requestedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
theme: {
|
||||
type: 'string',
|
||||
title: 'Theme',
|
||||
description: 'Choose your preferred theme',
|
||||
enum: ['light', 'dark', 'auto'],
|
||||
enumNames: ['Light', 'Dark', 'Auto'],
|
||||
},
|
||||
notifications: {
|
||||
type: 'boolean',
|
||||
title: 'Enable Notifications',
|
||||
description: 'Would you like to receive notifications?',
|
||||
default: true,
|
||||
},
|
||||
frequency: {
|
||||
type: 'string',
|
||||
title: 'Notification Frequency',
|
||||
description: 'How often would you like notifications?',
|
||||
enum: ['daily', 'weekly', 'monthly'],
|
||||
enumNames: ['Daily', 'Weekly', 'Monthly'],
|
||||
},
|
||||
},
|
||||
required: ['theme'],
|
||||
};
|
||||
break;
|
||||
case 'feedback':
|
||||
message = 'Please provide your feedback';
|
||||
requestedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rating: {
|
||||
type: 'integer',
|
||||
title: 'Rating',
|
||||
description: 'Rate your experience (1-5)',
|
||||
minimum: 1,
|
||||
maximum: 5,
|
||||
},
|
||||
comments: {
|
||||
type: 'string',
|
||||
title: 'Comments',
|
||||
description: 'Additional comments (optional)',
|
||||
maxLength: 500,
|
||||
},
|
||||
recommend: {
|
||||
type: 'boolean',
|
||||
title: 'Would you recommend this?',
|
||||
description: 'Would you recommend this to others?',
|
||||
},
|
||||
},
|
||||
required: ['rating', 'recommend'],
|
||||
};
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown info type: ${infoType}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the underlying server instance to elicit input from the client
|
||||
const result = await server.server.elicitInput({
|
||||
message,
|
||||
requestedSchema,
|
||||
});
|
||||
|
||||
if (result.action === 'accept') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Thank you! Collected ${infoType} information: ${JSON.stringify(result.content, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (result.action === 'reject') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `No information was collected. User rejected ${infoType} information request.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Information collection was cancelled by the user.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error collecting ${infoType} information: ${error}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Register a simple prompt with title
|
||||
server.registerPrompt(
|
||||
'greeting-template',
|
||||
{
|
||||
title: 'Greeting Template', // Display name for UI
|
||||
description: 'A simple greeting prompt template',
|
||||
argsSchema: {
|
||||
name: z.string().describe('Name to include in greeting'),
|
||||
},
|
||||
},
|
||||
async ({ name }): Promise<GetPromptResult> => {
|
||||
return {
|
||||
|
|
@ -148,10 +305,14 @@ const getServer = () => {
|
|||
);
|
||||
|
||||
// Create a simple resource at a fixed URI
|
||||
server.resource(
|
||||
server.registerResource(
|
||||
'greeting-resource',
|
||||
'https://example.com/greetings/default',
|
||||
{ mimeType: 'text/plain' },
|
||||
{
|
||||
title: 'Default Greeting', // Display name for UI
|
||||
description: 'A simple greeting resource',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
return {
|
||||
contents: [
|
||||
|
|
@ -163,6 +324,99 @@ const getServer = () => {
|
|||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Create additional resources for ResourceLink demonstration
|
||||
server.registerResource(
|
||||
'example-file-1',
|
||||
'file:///example/file1.txt',
|
||||
{
|
||||
title: 'Example File 1',
|
||||
description: 'First example file for ResourceLink demonstration',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'file:///example/file1.txt',
|
||||
text: 'This is the content of file 1',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'example-file-2',
|
||||
'file:///example/file2.txt',
|
||||
{
|
||||
title: 'Example File 2',
|
||||
description: 'Second example file for ResourceLink demonstration',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'file:///example/file2.txt',
|
||||
text: 'This is the content of file 2',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Register a tool that returns ResourceLinks
|
||||
server.registerTool(
|
||||
'list-files',
|
||||
{
|
||||
title: 'List Files with ResourceLinks',
|
||||
description: 'Returns a list of files as ResourceLinks without embedding their content',
|
||||
inputSchema: {
|
||||
includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links'),
|
||||
},
|
||||
},
|
||||
async ({ includeDescriptions = true }): Promise<CallToolResult> => {
|
||||
const resourceLinks: ResourceLink[] = [
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'https://example.com/greetings/default',
|
||||
name: 'Default Greeting',
|
||||
mimeType: 'text/plain',
|
||||
...(includeDescriptions && { description: 'A simple greeting resource' })
|
||||
},
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file:///example/file1.txt',
|
||||
name: 'Example File 1',
|
||||
mimeType: 'text/plain',
|
||||
...(includeDescriptions && { description: 'First example file for ResourceLink demonstration' })
|
||||
},
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file:///example/file2.txt',
|
||||
name: 'Example File 2',
|
||||
mimeType: 'text/plain',
|
||||
...(includeDescriptions && { description: 'Second example file for ResourceLink demonstration' })
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Here are the available files as resource links:',
|
||||
},
|
||||
...resourceLinks,
|
||||
{
|
||||
type: 'text',
|
||||
text: '\nYou can read any of these resources using their URI.',
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
};
|
||||
|
||||
|
|
@ -176,10 +430,10 @@ app.use(express.json());
|
|||
let authMiddleware = null;
|
||||
if (useOAuth) {
|
||||
// Create auth middleware for MCP endpoints
|
||||
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`);
|
||||
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
|
||||
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
|
||||
|
||||
const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl);
|
||||
const oauthMetadata: OAuthMetadata = setupAuthServer({authServerUrl, mcpServerUrl, strictResource: strictOAuth});
|
||||
|
||||
const tokenVerifier = {
|
||||
verifyAccessToken: async (token: string) => {
|
||||
|
|
@ -206,6 +460,15 @@ if (useOAuth) {
|
|||
|
||||
const data = await response.json();
|
||||
|
||||
if (strictOAuth) {
|
||||
if (!data.aud) {
|
||||
throw new Error(`Resource Indicator (RFC8707) missing`);
|
||||
}
|
||||
if (!checkResourceAllowed({ requestedResource: data.aud, configuredResource: mcpServerUrl })) {
|
||||
throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the response to AuthInfo format
|
||||
return {
|
||||
token,
|
||||
|
|
@ -225,7 +488,7 @@ if (useOAuth) {
|
|||
|
||||
authMiddleware = requireBearerAuth({
|
||||
verifier: tokenVerifier,
|
||||
requiredScopes: ['mcp:tools'],
|
||||
requiredScopes: [],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
|
||||
});
|
||||
}
|
||||
|
|
@ -235,15 +498,13 @@ const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
|
|||
|
||||
// MCP POST endpoint with optional auth
|
||||
const mcpPostHandler = async (req: Request, res: Response) => {
|
||||
console.log('Received MCP request:', req.body);
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
console.log(sessionId? `Received MCP request for session: ${sessionId}`: 'Received MCP request:', req.body);
|
||||
if (useOAuth && req.auth) {
|
||||
console.log('Authenticated user:', req.auth);
|
||||
}
|
||||
try {
|
||||
// Check for existing session ID
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
|
||||
if (sessionId && transports[sessionId]) {
|
||||
// Reuse existing transport
|
||||
transport = transports[sessionId];
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Client } from '../client/index.js';
|
|||
import { StreamableHTTPClientTransport } from '../client/streamableHttp.js';
|
||||
import { McpServer } from '../server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../server/streamableHttp.js';
|
||||
import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema } from '../types.js';
|
||||
import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema, LATEST_PROTOCOL_VERSION } from '../types.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
describe('Streamable HTTP Transport Session Management', () => {
|
||||
|
|
@ -145,7 +145,7 @@ describe('Streamable HTTP Transport Session Management', () => {
|
|||
params: {}
|
||||
}, ListToolsResultSchema);
|
||||
|
||||
|
||||
|
||||
});
|
||||
it('should operate without session management', async () => {
|
||||
// Create and connect a client
|
||||
|
|
@ -211,6 +211,27 @@ describe('Streamable HTTP Transport Session Management', () => {
|
|||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it('should set protocol version after connecting', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
|
||||
// Verify protocol version is not set before connecting
|
||||
expect(transport.protocolVersion).toBeUndefined();
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify protocol version is set after connecting
|
||||
expect(transport.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stateful Mode', () => {
|
||||
|
|
|
|||
|
|
@ -276,6 +276,34 @@ describe('Authorization Handler', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Resource parameter validation', () => {
|
||||
it('propagates resource parameter', async () => {
|
||||
const mockProviderWithResource = jest.spyOn(mockProvider, 'authorize');
|
||||
|
||||
const response = await supertest(app)
|
||||
.get('/authorize')
|
||||
.query({
|
||||
client_id: 'valid-client',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
response_type: 'code',
|
||||
code_challenge: 'challenge123',
|
||||
code_challenge_method: 'S256',
|
||||
resource: 'https://api.example.com/resource'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(mockProviderWithResource).toHaveBeenCalledWith(
|
||||
validClient,
|
||||
expect.objectContaining({
|
||||
resource: new URL('https://api.example.com/resource'),
|
||||
redirectUri: 'https://example.com/callback',
|
||||
codeChallenge: 'challenge123'
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Successful authorization', () => {
|
||||
it('handles successful authorization with all parameters', async () => {
|
||||
const response = await supertest(app)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const RequestAuthorizationParamsSchema = z.object({
|
|||
code_challenge_method: z.literal("S256"),
|
||||
scope: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
resource: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler {
|
||||
|
|
@ -98,7 +99,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
|
|||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
console.error("Unexpected error looking up client:", error);
|
||||
const serverError = new ServerError("Internal Server Error");
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
|
|
@ -115,7 +115,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
|
|||
throw new InvalidRequestError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const { scope, code_challenge } = parseResult.data;
|
||||
const { scope, code_challenge, resource } = parseResult.data;
|
||||
state = parseResult.data.state;
|
||||
|
||||
// Validate scopes
|
||||
|
|
@ -138,13 +138,13 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
|
|||
scopes: requestedScopes,
|
||||
redirectUri: redirect_uri,
|
||||
codeChallenge: code_challenge,
|
||||
resource: resource ? new URL(resource) : undefined,
|
||||
}, res);
|
||||
} catch (error) {
|
||||
// Post-redirect errors - redirect with error parameters
|
||||
if (error instanceof OAuthError) {
|
||||
res.redirect(302, createErrorRedirect(redirect_uri, error, state));
|
||||
} else {
|
||||
console.error("Unexpected error during authorization:", error);
|
||||
const serverError = new ServerError("Internal Server Error");
|
||||
res.redirect(302, createErrorRedirect(redirect_uri, serverError, state));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,6 @@ export function clientRegistrationHandler({
|
|||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
console.error("Unexpected error registering client:", error);
|
||||
const serverError = new ServerError("Internal Server Error");
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
InvalidRequestError,
|
||||
ServerError,
|
||||
TooManyRequestsError,
|
||||
OAuthError
|
||||
OAuthError,
|
||||
} from "../errors.js";
|
||||
|
||||
export type RevocationHandlerOptions = {
|
||||
|
|
@ -21,7 +21,10 @@ export type RevocationHandlerOptions = {
|
|||
rateLimit?: Partial<RateLimitOptions> | false;
|
||||
};
|
||||
|
||||
export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler {
|
||||
export function revocationHandler({
|
||||
provider,
|
||||
rateLimit: rateLimitConfig,
|
||||
}: RevocationHandlerOptions): RequestHandler {
|
||||
if (!provider.revokeToken) {
|
||||
throw new Error("Auth provider does not support revoking tokens");
|
||||
}
|
||||
|
|
@ -37,21 +40,25 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo
|
|||
|
||||
// Apply rate limiting unless explicitly disabled
|
||||
if (rateLimitConfig !== false) {
|
||||
router.use(rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 50, // 50 requests per windowMs
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(),
|
||||
...rateLimitConfig
|
||||
}));
|
||||
router.use(
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 50, // 50 requests per windowMs
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: new TooManyRequestsError(
|
||||
"You have exceeded the rate limit for token revocation requests"
|
||||
).toResponseObject(),
|
||||
...rateLimitConfig,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticate and extract client details
|
||||
router.use(authenticateClient({ clientsStore: provider.clientsStore }));
|
||||
|
||||
router.post("/", async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
|
||||
try {
|
||||
const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body);
|
||||
|
|
@ -62,7 +69,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo
|
|||
const client = req.client;
|
||||
if (!client) {
|
||||
// This should never happen
|
||||
console.error("Missing client information after authentication");
|
||||
throw new ServerError("Internal Server Error");
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +79,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo
|
|||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
console.error("Unexpected error revoking token:", error);
|
||||
const serverError = new ServerError("Internal Server Error");
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -264,12 +264,14 @@ describe('Token Handler', () => {
|
|||
});
|
||||
|
||||
it('returns tokens for valid code exchange', async () => {
|
||||
const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode');
|
||||
const response = await supertest(app)
|
||||
.post('/token')
|
||||
.type('form')
|
||||
.send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
resource: 'https://api.example.com/resource',
|
||||
grant_type: 'authorization_code',
|
||||
code: 'valid_code',
|
||||
code_verifier: 'valid_verifier'
|
||||
|
|
@ -280,6 +282,13 @@ describe('Token Handler', () => {
|
|||
expect(response.body.token_type).toBe('bearer');
|
||||
expect(response.body.expires_in).toBe(3600);
|
||||
expect(response.body.refresh_token).toBe('mock_refresh_token');
|
||||
expect(mockExchangeCode).toHaveBeenCalledWith(
|
||||
validClient,
|
||||
'valid_code',
|
||||
undefined, // code_verifier is undefined after PKCE validation
|
||||
undefined, // redirect_uri
|
||||
new URL('https://api.example.com/resource') // resource parameter
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through code verifier when using proxy provider', async () => {
|
||||
|
|
@ -440,12 +449,14 @@ describe('Token Handler', () => {
|
|||
});
|
||||
|
||||
it('returns new tokens for valid refresh token', async () => {
|
||||
const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken');
|
||||
const response = await supertest(app)
|
||||
.post('/token')
|
||||
.type('form')
|
||||
.send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
resource: 'https://api.example.com/resource',
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'valid_refresh_token'
|
||||
});
|
||||
|
|
@ -455,6 +466,12 @@ describe('Token Handler', () => {
|
|||
expect(response.body.token_type).toBe('bearer');
|
||||
expect(response.body.expires_in).toBe(3600);
|
||||
expect(response.body.refresh_token).toBe('new_mock_refresh_token');
|
||||
expect(mockExchangeRefresh).toHaveBeenCalledWith(
|
||||
validClient,
|
||||
'valid_refresh_token',
|
||||
undefined, // scopes
|
||||
new URL('https://api.example.com/resource') // resource parameter
|
||||
);
|
||||
});
|
||||
|
||||
it('respects requested scopes on refresh', async () => {
|
||||
|
|
|
|||
|
|
@ -32,11 +32,13 @@ const AuthorizationCodeGrantSchema = z.object({
|
|||
code: z.string(),
|
||||
code_verifier: z.string(),
|
||||
redirect_uri: z.string().optional(),
|
||||
resource: z.string().url().optional(),
|
||||
});
|
||||
|
||||
const RefreshTokenGrantSchema = z.object({
|
||||
refresh_token: z.string(),
|
||||
scope: z.string().optional(),
|
||||
resource: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler {
|
||||
|
|
@ -78,7 +80,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
|
|||
const client = req.client;
|
||||
if (!client) {
|
||||
// This should never happen
|
||||
console.error("Missing client information after authentication");
|
||||
throw new ServerError("Internal Server Error");
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +90,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
|
|||
throw new InvalidRequestError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const { code, code_verifier, redirect_uri } = parseResult.data;
|
||||
const { code, code_verifier, redirect_uri, resource } = parseResult.data;
|
||||
|
||||
const skipLocalPkceValidation = provider.skipLocalPkceValidation;
|
||||
|
||||
|
|
@ -107,7 +108,8 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
|
|||
client,
|
||||
code,
|
||||
skipLocalPkceValidation ? code_verifier : undefined,
|
||||
redirect_uri
|
||||
redirect_uri,
|
||||
resource ? new URL(resource) : undefined
|
||||
);
|
||||
res.status(200).json(tokens);
|
||||
break;
|
||||
|
|
@ -119,10 +121,10 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
|
|||
throw new InvalidRequestError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const { refresh_token, scope } = parseResult.data;
|
||||
const { refresh_token, scope, resource } = parseResult.data;
|
||||
|
||||
const scopes = scope?.split(" ");
|
||||
const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes);
|
||||
const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource ? new URL(resource) : undefined);
|
||||
res.status(200).json(tokens);
|
||||
break;
|
||||
}
|
||||
|
|
@ -140,7 +142,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
|
|||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
console.error("Unexpected error exchanging token:", error);
|
||||
const serverError = new ServerError("Internal Server Error");
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue