Skip to content

Feat: Separate authorization server and resource server on client auth flow #416

New issue

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

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

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
170 changes: 170 additions & 0 deletions src/client/auth.test.ts
Original file line number Diff line number Diff line change
@@ -4,6 +4,8 @@ import {
exchangeAuthorization,
refreshAuthorization,
registerClient,
discoverOAuthProtectedResourceMetadata,
extractResourceMetadataUrl,
} from "./auth.js";

// Mock fetch globally
@@ -15,6 +17,168 @@ describe("OAuth Authorization", () => {
mockFetch.mockReset();
});

describe("extractResourceMetadataUrl", () => {
it("returns resource metadata url when present", async () => {
const resourceUrl = "https://resource.example.com/.well-known/oauth-protected-resource"
const mockResponse = {
headers: {
get: jest.fn((name) => name === "WWW-Authenticate" ? `Bearer realm="mcp", resource_metadata="${resourceUrl}"` : null),
}
} as unknown as Response

expect(extractResourceMetadataUrl(mockResponse)).toEqual(new URL(resourceUrl));
});

it("returns undefined if not bearer", async () => {
const resourceUrl = "https://resource.example.com/.well-known/oauth-protected-resource"
const mockResponse = {
headers: {
get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null),
}
} as unknown as Response

expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
});

it("returns undefined if resource_metadata not present", async () => {
const mockResponse = {
headers: {
get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp"` : null),
}
} as unknown as Response

expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
});

it("returns undefined on invalid url", async () => {
const resourceUrl = "invalid-url"
const mockResponse = {
headers: {
get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null),
}
} as unknown as Response

expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined();
});
});

describe("discoverOAuthProtectedResourceMetadata", () => {
const validMetadata = {
resource: "https://resource.example.com",
authorization_servers: ["https://auth.example.com"],
};

it("returns metadata when discovery succeeds", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validMetadata,
});

const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com");
expect(metadata).toEqual(validMetadata);
const calls = mockFetch.mock.calls;
expect(calls.length).toBe(1);
const [url, options] = calls[0];
expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
expect(options.headers).toEqual({
"MCP-Protocol-Version": "2024-11-05"
});
});

it("returns metadata when first fetch fails but second without MCP header succeeds", async () => {
// Set up a counter to control behavior
let callCount = 0;

// Mock implementation that changes behavior based on call count
mockFetch.mockImplementation((_url, _options) => {
callCount++;

if (callCount === 1) {
// First call with MCP header - fail with TypeError (simulating CORS error)
// We need to use TypeError specifically because that's what the implementation checks for
return Promise.reject(new TypeError("Network error"));
} else {
// Second call without header - succeed
return Promise.resolve({
ok: true,
status: 200,
json: async () => validMetadata
});
}
});

// Should succeed with the second call
const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com");
expect(metadata).toEqual(validMetadata);

// Verify both calls were made
expect(mockFetch).toHaveBeenCalledTimes(2);

// Verify first call had MCP header
expect(mockFetch.mock.calls[0][1]?.headers).toHaveProperty("MCP-Protocol-Version");
});

it("throws an error when all fetch attempts fail", async () => {
// Set up a counter to control behavior
let callCount = 0;

// Mock implementation that changes behavior based on call count
mockFetch.mockImplementation((_url, _options) => {
callCount++;

if (callCount === 1) {
// First call - fail with TypeError
return Promise.reject(new TypeError("First failure"));
} else {
// Second call - fail with different error
return Promise.reject(new Error("Second failure"));
}
});

// Should fail with the second error
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
.rejects.toThrow("Second failure");

// Verify both calls were made
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it("throws on 404 errors", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
});

await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
.rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
});

it("throws on non-404 errors", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
});

await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
.rejects.toThrow("HTTP 500");
});

it("validates metadata schema", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
// Missing required fields
scopes_supported: ["email", "mcp"],
}),
});

await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
.rejects.toThrow();
});
});

describe("discoverOAuthMetadata", () => {
const validMetadata = {
issuer: "https://auth.example.com",
@@ -158,6 +322,8 @@ describe("OAuth Authorization", () => {
const { authorizationUrl, codeVerifier } = await startAuthorization(
"https://auth.example.com",
{
resource: "https://resource.example.com",
metadata: undefined,
clientInformation: validClientInfo,
redirectUrl: "http://localhost:3000/callback",
}
@@ -166,6 +332,7 @@ describe("OAuth Authorization", () => {
expect(authorizationUrl.toString()).toMatch(
/^https:\/\/auth\.example\.com\/authorize\?/
);
expect(authorizationUrl.searchParams.get("resource")).toBe("https://resource.example.com");
expect(authorizationUrl.searchParams.get("response_type")).toBe("code");
expect(authorizationUrl.searchParams.get("code_challenge")).toBe("test_challenge");
expect(authorizationUrl.searchParams.get("code_challenge_method")).toBe(
@@ -181,6 +348,7 @@ describe("OAuth Authorization", () => {
const { authorizationUrl } = await startAuthorization(
"https://auth.example.com",
{
resource: "https://resource.example.com",
metadata: validMetadata,
clientInformation: validClientInfo,
redirectUrl: "http://localhost:3000/callback",
@@ -200,6 +368,7 @@ describe("OAuth Authorization", () => {

await expect(
startAuthorization("https://auth.example.com", {
resource: "https://resource.example.com",
metadata,
clientInformation: validClientInfo,
redirectUrl: "http://localhost:3000/callback",
@@ -216,6 +385,7 @@ describe("OAuth Authorization", () => {

await expect(
startAuthorization("https://auth.example.com", {
resource: "https://resource.example.com",
metadata,
clientInformation: validClientInfo,
redirectUrl: "http://localhost:3000/callback",
132 changes: 110 additions & 22 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import pkceChallenge from "pkce-challenge";
import { LATEST_PROTOCOL_VERSION } from "../types.js";
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull } from "../shared/auth.js";
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js";
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";

/**
* Implements an end-to-end OAuth client to be used with one MCP server.
*
*
* This client relies upon a concept of an authorized "session," the exact
* meaning of which is application-defined. Tokens, authorization codes, and
* code verifiers should not cross different sessions.
@@ -32,7 +32,7 @@ export interface OAuthClientProvider {
* If implemented, this permits the OAuth client to dynamically register with
* the server. Client information saved this way should later be read via
* `clientInformation()`.
*
*
* This method is not required to be implemented if client information is
* statically known (e.g., pre-registered).
*/
@@ -78,14 +78,22 @@ export class UnauthorizedError extends Error {

/**
* Orchestrates the full auth flow with a server.
*
*
* This can be used as a single entry point for all authorization functionality,
* instead of linking together the other lower-level functions in this module.
*/
export async function auth(
provider: OAuthClientProvider,
{ serverUrl, authorizationCode }: { serverUrl: string | URL, authorizationCode?: string }): Promise<AuthResult> {
const metadata = await discoverOAuthMetadata(serverUrl);
{ resourceServerUrl, authorizationCode, protectedResourceMetadata }: { resourceServerUrl: string | URL, authorizationCode?: string, protectedResourceMetadata?: OAuthProtectedResourceMetadata }): Promise<AuthResult> {

let resourceMetadata = protectedResourceMetadata ?? await discoverOAuthProtectedResourceMetadata(resourceServerUrl);

if (resourceMetadata.authorization_servers === undefined || resourceMetadata.authorization_servers.length === 0) {
throw new Error("Server does not speicify any authorization servers.");
}
const authorizationServerUrl = resourceMetadata.authorization_servers[0];

const metadata = await discoverOAuthMetadata(authorizationServerUrl);

// Handle client registration if needed
let clientInformation = await Promise.resolve(provider.clientInformation());
@@ -98,7 +106,7 @@ export async function auth(
throw new Error("OAuth client information must be saveable for dynamic registration");
}

const fullInformation = await registerClient(serverUrl, {
const fullInformation = await registerClient(authorizationServerUrl, {
metadata,
clientMetadata: provider.clientMetadata,
});
@@ -110,7 +118,7 @@ export async function auth(
// Exchange authorization code for tokens
if (authorizationCode !== undefined) {
const codeVerifier = await provider.codeVerifier();
const tokens = await exchangeAuthorization(serverUrl, {
const tokens = await exchangeAuthorization(authorizationServerUrl, {
metadata,
clientInformation,
authorizationCode,
@@ -128,7 +136,7 @@ export async function auth(
if (tokens?.refresh_token) {
try {
// Attempt to refresh the token
const newTokens = await refreshAuthorization(serverUrl, {
const newTokens = await refreshAuthorization(authorizationServerUrl, {
metadata,
clientInformation,
refreshToken: tokens.refresh_token,
@@ -142,7 +150,8 @@ export async function auth(
}

// Start new authorization flow
const { authorizationUrl, codeVerifier } = await startAuthorization(serverUrl, {
const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, {
resource: resourceMetadata.resource,
metadata,
clientInformation,
redirectUrl: provider.redirectUrl
@@ -153,17 +162,93 @@ export async function auth(
return "REDIRECT";
}

/**
* Extract resource_metadata from response header.
*/
export function extractResourceMetadataUrl(res: Response): URL | undefined {

const authenticateHeader = res.headers.get("WWW-Authenticate");
if (!authenticateHeader) {
return 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="([^"]*)"/;
const match = regex.exec(authenticateHeader);

if (!match) {
return undefined;
}

try {
return new URL(match[1]);
} catch {
console.log("Invalid resource metadata url: ", match[1]);
return undefined;
}
}

/**
* Looks up RFC 9728 OAuth 2.0 Protected Resource Metadata.
*
* If the server returns a 404 for the well-known endpoint, this function will
* return `undefined`. Any other errors will be thrown as exceptions.
*/
export async function discoverOAuthProtectedResourceMetadata(
resourceServerUrl: string | URL,
opts?: { protocolVersion?: string, resourceMetadataUrl?: string | URL },
): Promise<OAuthProtectedResourceMetadata> {

let url: URL
if (opts?.resourceMetadataUrl) {
url = new URL(opts?.resourceMetadataUrl);
} else {
url = new URL("/.well-known/oauth-protected-resource", resourceServerUrl);
}

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;
}
}

if (response.status === 404) {
throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`);
}

if (!response.ok) {
throw new Error(
`HTTP ${response.status} trying to load well-known OAuth protected resource metadata.`,
);
}
return OAuthProtectedResourceMetadataSchema.parse(await response.json());
}

/**
* Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata.
*
* If the server returns a 404 for the well-known endpoint, this function will
* return `undefined`. Any other errors will be thrown as exceptions.
*/
export async function discoverOAuthMetadata(
serverUrl: string | URL,
authorizationServerUrl: string | URL,
opts?: { protocolVersion?: string },
): Promise<OAuthMetadata | undefined> {
const url = new URL("/.well-known/oauth-authorization-server", serverUrl);
const url = new URL("/.well-known/oauth-authorization-server", authorizationServerUrl);
let response: Response;
try {
response = await fetch(url, {
@@ -197,12 +282,14 @@ export async function discoverOAuthMetadata(
* Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL.
*/
export async function startAuthorization(
serverUrl: string | URL,
authorizationServerUrl: string | URL,
{
resource,
metadata,
clientInformation,
redirectUrl,
}: {
resource: string | URL;
metadata?: OAuthMetadata;
clientInformation: OAuthClientInformation;
redirectUrl: string | URL;
@@ -230,7 +317,7 @@ export async function startAuthorization(
);
}
} else {
authorizationUrl = new URL("/authorize", serverUrl);
authorizationUrl = new URL("/authorize", authorizationServerUrl);
}

// Generate PKCE challenge
@@ -246,6 +333,7 @@ export async function startAuthorization(
codeChallengeMethod,
);
authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl));
authorizationUrl.searchParams.set("resource", String(resource));

return { authorizationUrl, codeVerifier };
}
@@ -254,7 +342,7 @@ export async function startAuthorization(
* Exchanges an authorization code for an access token with the given server.
*/
export async function exchangeAuthorization(
serverUrl: string | URL,
authorizationServerUrl: string | URL,
{
metadata,
clientInformation,
@@ -284,7 +372,7 @@ export async function exchangeAuthorization(
);
}
} else {
tokenUrl = new URL("/token", serverUrl);
tokenUrl = new URL("/token", authorizationServerUrl);
}

// Exchange code for tokens
@@ -319,7 +407,7 @@ export async function exchangeAuthorization(
* Exchange a refresh token for an updated access token.
*/
export async function refreshAuthorization(
serverUrl: string | URL,
authorizationServerUrl: string | URL,
{
metadata,
clientInformation,
@@ -345,7 +433,7 @@ export async function refreshAuthorization(
);
}
} else {
tokenUrl = new URL("/token", serverUrl);
tokenUrl = new URL("/token", authorizationServerUrl);
}

// Exchange refresh token
@@ -366,7 +454,7 @@ export async function refreshAuthorization(
},
body: params,
});

console.log(response)
if (!response.ok) {
throw new Error(`Token refresh failed: HTTP ${response.status}`);
}
@@ -378,7 +466,7 @@ export async function refreshAuthorization(
* Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591.
*/
export async function registerClient(
serverUrl: string | URL,
authorizationServerUrl: string | URL,
{
metadata,
clientMetadata,
@@ -396,7 +484,7 @@ export async function registerClient(

registrationUrl = new URL(metadata.registration_endpoint);
} else {
registrationUrl = new URL("/register", serverUrl);
registrationUrl = new URL("/register", authorizationServerUrl);
}

const response = await fetch(registrationUrl, {
302 changes: 232 additions & 70 deletions src/client/sse.test.ts

Large diffs are not rendered by default.

27 changes: 18 additions & 9 deletions src/client/sse.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource";
import { Transport } from "../shared/transport.js";
import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { auth, AuthResult, discoverOAuthProtectedResourceMetadata, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { OAuthProtectedResourceMetadata } from "src/shared/auth.js";

export class SseError extends Error {
constructor(
@@ -19,23 +20,23 @@ export class SseError extends Error {
export type SSEClientTransportOptions = {
/**
* An OAuth client provider to use for authentication.
*
*
* When an `authProvider` is specified and the SSE connection is started:
* 1. The connection is attempted with any existing access token from the `authProvider`.
* 2. If the access token has expired, the `authProvider` is used to refresh the token.
* 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`.
*
*
* After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `SSEClientTransport.finishAuth` with the authorization code before retrying the connection.
*
*
* If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown.
*
*
* `UnauthorizedError` might also be thrown when sending any message over the SSE transport, indicating that the session has expired, and needs to be re-authed and reconnected.
*/
authProvider?: OAuthClientProvider;

/**
* Customizes the initial SSE request to the server (the request that begins the stream).
*
*
* NOTE: Setting this property will prevent an `Authorization` header from
* being automatically attached to the SSE request, if an `authProvider` is
* also given. This can be worked around by setting the `Authorization` header
@@ -58,6 +59,7 @@ export class SSEClientTransport implements Transport {
private _endpoint?: URL;
private _abortController?: AbortController;
private _url: URL;
private _protectedResourceMetadata: OAuthProtectedResourceMetadata | undefined;
private _eventSourceInit?: EventSourceInit;
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
@@ -71,6 +73,7 @@ export class SSEClientTransport implements Transport {
opts?: SSEClientTransportOptions,
) {
this._url = url;
this._protectedResourceMetadata = undefined;
this._eventSourceInit = opts?.eventSourceInit;
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
@@ -83,7 +86,7 @@ export class SSEClientTransport implements Transport {

let result: AuthResult;
try {
result = await auth(this._authProvider, { serverUrl: this._url });
result = await auth(this._authProvider, { resourceServerUrl: this._url, protectedResourceMetadata: this._protectedResourceMetadata });
} catch (error) {
this.onerror?.(error as Error);
throw error;
@@ -193,7 +196,7 @@ export class SSEClientTransport implements Transport {
throw new UnauthorizedError("No auth provider");
}

const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode });
const result = await auth(this._authProvider, { resourceServerUrl: this._url, authorizationCode, protectedResourceMetadata: this._protectedResourceMetadata });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError("Failed to authorize");
}
@@ -225,7 +228,13 @@ export class SSEClientTransport implements Transport {
const response = await fetch(this._endpoint, init);
if (!response.ok) {
if (response.status === 401 && this._authProvider) {
const result = await auth(this._authProvider, { serverUrl: this._url });

const resourceMetadataUrl = extractResourceMetadataUrl(response);
this._protectedResourceMetadata = await discoverOAuthProtectedResourceMetadata(this._url, {
resourceMetadataUrl: resourceMetadataUrl
})

const result = await auth(this._authProvider, { resourceServerUrl: this._url, protectedResourceMetadata: this._protectedResourceMetadata });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError();
}
25 changes: 17 additions & 8 deletions src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { OAuthProtectedResourceMetadata } from "src/shared/auth.js";
import { Transport } from "../shared/transport.js";
import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { auth, AuthResult, discoverOAuthProtectedResourceMetadata, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { EventSourceParserStream } from "eventsource-parser/stream";

// Default reconnection options for StreamableHTTP connections
@@ -119,6 +120,7 @@ export type StreamableHTTPClientTransportOptions = {
export class StreamableHTTPClientTransport implements Transport {
private _abortController?: AbortController;
private _url: URL;
private _protectedResourceMetadata: OAuthProtectedResourceMetadata | undefined;
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
private _sessionId?: string;
@@ -133,6 +135,7 @@ export class StreamableHTTPClientTransport implements Transport {
opts?: StreamableHTTPClientTransportOptions,
) {
this._url = url;
this._protectedResourceMetadata = undefined;
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
this._sessionId = opts?.sessionId;
@@ -146,7 +149,7 @@ export class StreamableHTTPClientTransport implements Transport {

let result: AuthResult;
try {
result = await auth(this._authProvider, { serverUrl: this._url });
result = await auth(this._authProvider, { resourceServerUrl: this._url, protectedResourceMetadata: this._protectedResourceMetadata });
} catch (error) {
this.onerror?.(error as Error);
throw error;
@@ -225,7 +228,7 @@ export class StreamableHTTPClientTransport implements Transport {

/**
* Calculates the next reconnection delay using backoff algorithm
*
*
* @param attempt Current reconnection attempt count for the specific stream
* @returns Time to wait in milliseconds before next reconnection attempt
*/
@@ -242,7 +245,7 @@ export class StreamableHTTPClientTransport implements Transport {

/**
* Schedule a reconnection attempt with exponential backoff
*
*
* @param lastEventId The ID of the last received event for resumability
* @param attemptCount Current reconnection attempt count for this specific stream
*/
@@ -356,7 +359,7 @@ export class StreamableHTTPClientTransport implements Transport {
throw new UnauthorizedError("No auth provider");
}

const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode });
const result = await auth(this._authProvider, { resourceServerUrl: this._url, authorizationCode, protectedResourceMetadata: this._protectedResourceMetadata });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError("Failed to authorize");
}
@@ -401,7 +404,13 @@ export class StreamableHTTPClientTransport implements Transport {

if (!response.ok) {
if (response.status === 401 && this._authProvider) {
const result = await auth(this._authProvider, { serverUrl: this._url });

const resourceMetadataUrl = extractResourceMetadataUrl(response);
this._protectedResourceMetadata = await discoverOAuthProtectedResourceMetadata(this._url, {
resourceMetadataUrl: resourceMetadataUrl
})

const result = await auth(this._authProvider, { resourceServerUrl: this._url, protectedResourceMetadata: this._protectedResourceMetadata });
if (result !== "AUTHORIZED") {
throw new UnauthorizedError();
}
@@ -470,12 +479,12 @@ export class StreamableHTTPClientTransport implements Transport {

/**
* Terminates the current session by sending a DELETE request to the server.
*
*
* Clients that no longer need a particular session
* (e.g., because the user is leaving the client application) SHOULD send an
* HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly
* terminate the session.
*
*
* The server MAY respond with HTTP 405 Method Not Allowed, indicating that
* the server does not allow clients to terminate sessions.
*/
23 changes: 23 additions & 0 deletions src/shared/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
import { z } from "zod";

/**
* RFC 8414 OAuth 2.0 Authorization Server Metadata
*/
export const OAuthProtectedResourceMetadataSchema = z
.object({
resource: z.string(),
authorization_servers: z.array(z.string()).optional(),
jwks_uri: z.string().optional(),
scopes_supported: z.array(z.string()).optional(),
bearer_methods_supported: z.array(z.string()).optional(),
resource_signing_alg_values_supported: z.array(z.string()).optional(),
resource_name: z.string().optional(),
resource_documentation: z.string().optional(),
resource_policy_uri: z.string().optional(),
resource_tos_uri: z.string().optional(),
tls_client_certificate_bound_access_tokens: z.boolean().optional(),
authorization_details_types_supported: z.array(z.string()).optional(),
dpop_signing_alg_values_supported: z.array(z.string()).optional(),
dpop_bound_access_tokens_required: z.boolean().optional(),
})
.passthrough();

/**
* RFC 8414 OAuth 2.0 Authorization Server Metadata
*/
@@ -109,6 +131,7 @@ export const OAuthTokenRevocationRequestSchema = z.object({
token_type_hint: z.string().optional(),
}).strip();

export type OAuthProtectedResourceMetadata = z.infer<typeof OAuthProtectedResourceMetadataSchema>;
export type OAuthMetadata = z.infer<typeof OAuthMetadataSchema>;
export type OAuthTokens = z.infer<typeof OAuthTokensSchema>;
export type OAuthErrorResponse = z.infer<typeof OAuthErrorResponseSchema>;