Migrate all three clients + core + test-servers from
@modelcontextprotocol/sdk@1.29 to SDK v2 (2.0.0-beta.4), keeping behavior
identical against legacy (2025-11-25) servers. Foundation for the SDK V2 +
New Spec stack.
Packages
- Split @modelcontextprotocol/sdk -> @modelcontextprotocol/client (Client,
transports, OAuth, error taxonomy, protocol types) + core (Zod schemas);
stdio -> client/stdio, Ajv -> client/validators/ajv. Test-servers ->
@modelcontextprotocol/server (+ server-legacy/sse). Deterministic import
rewrite (schemas -> core, everything else -> client); the codemod misroutes
client code to /server so it was not used for path routing.
- new Client(..., { versionNegotiation: { mode: 'legacy' } }) — nothing
2026-era goes on the wire.
Runtime (core/mcp/inspectorClient.ts)
- Error taxonomy: McpError -> ProtocolError, ErrorCode -> ProtocolErrorCode;
RequestTimeout -> SdkError/SdkErrorCode.
- Handlers take method strings; task methods (tasks/*, notifications/tasks/*)
use the 3-arg custom form (excluded from v2's spec-method set).
- Legacy tasks kept working (per project requirement): requestor tasks re-
implemented over raw client.request + explicit deprecated schemas;
experimental.tasks.callToolStream replaced by a local pollTaskToolCall
async generator (with _progressHandlers revival so task-execution progress
still reaches the caller). Receiver (server-initiated) tasks: the v2 Client
validates a sampling/elicit handler result and rejects the legacy { task }
response with -32602 — installReceiverTaskResponseBypass swaps the wrapped
entry in the private _requestHandlers map for the task-augmented branch so
{ task } reaches the wire (fixes server-initiated sampling-via-task).
- Pagination: list verbs use raw client.request({method}, List*ResultSchema)
for single-page behavior (v2's client.listX() auto-aggregate all pages).
Web / auth / apps
- Rename local JsonSchemaType -> InspectorFormSchema + add toFormSchema()
structural narrowing at the SDK-schema -> form boundary.
- ext-apps still peers on SDK v1: one compat boundary with documented casts
(remove when ext-apps#702 lands a v2 peer).
- OAuth provider saveClientInformation reconciled with v2's
OAuthClientInformationContext; InvalidClientMetadataError re-homed as a
local class (server-legacy/auth is not browser-safe).
- vitest.shared.mts dedupes @modelcontextprotocol/client+core to one copy so
vi.mock intercepts the SUT's import.
Accepted v2 behavior changes (per #1624 / spec §9.2): unknown-tool call
rejects with -32602; cancellation is stream-abort (no guaranteed
notifications/cancelled frame); output-schema strictness restored via the
Inspector's own Ajv check on the default path.
npm run ci green: validate (web 3087 / cli 212 / tui 274 / launcher 5),
coverage (>=90 per-file, all clients), smoke (launcher/cli/web), storybook
(410).
Closes #1624
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1034 lines
29 KiB
TypeScript
1034 lines
29 KiB
TypeScript
/**
|
|
* OAuth Test Server Infrastructure
|
|
*
|
|
* Provides OAuth 2.1 authorization server functionality for test servers.
|
|
* Integrates with Express apps to add OAuth endpoints and Bearer token verification.
|
|
*/
|
|
|
|
import crypto from "node:crypto";
|
|
import type { Request, Response } from "express";
|
|
import express from "express";
|
|
import type { ServerConfig } from "./composable-test-server.js";
|
|
import { ExternalAccessTokenValidator } from "./test-server-oauth-jwt.js";
|
|
|
|
type OAuthRequest = Request & {
|
|
oauthToken?: string;
|
|
oauthTokenScopes?: string[];
|
|
};
|
|
|
|
/**
|
|
* OAuth configuration from ServerConfig
|
|
*/
|
|
export type OAuthConfig = NonNullable<ServerConfig["oauth"]>;
|
|
|
|
export function getOAuthMode(
|
|
config: OAuthConfig,
|
|
): "combined" | "protected-resource" {
|
|
return config.mode ?? "combined";
|
|
}
|
|
|
|
/**
|
|
* Set up OAuth routes on an Express application
|
|
* This adds all OAuth endpoints (authorization, token, metadata, etc.)
|
|
*
|
|
* @param app - Express application
|
|
* @param config - OAuth configuration
|
|
*/
|
|
export function setupOAuthRoutes(
|
|
app: express.Application,
|
|
config: OAuthConfig,
|
|
): void {
|
|
setupMetadataEndpoints(app, config);
|
|
|
|
if (getOAuthMode(config) === "combined") {
|
|
setupAuthorizationEndpoint(app, config);
|
|
setupTokenEndpoint(app, config);
|
|
if (config.supportDCR) {
|
|
setupDCREndpoint(app);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create Bearer token verification middleware
|
|
* Returns 401 if token is missing or invalid when requireAuth is true
|
|
*
|
|
* @param config - OAuth configuration
|
|
* @returns Express middleware function
|
|
*/
|
|
export function createBearerTokenMiddleware(
|
|
config: OAuthConfig,
|
|
): express.RequestHandler {
|
|
const mode = getOAuthMode(config);
|
|
const externalValidator =
|
|
mode === "protected-resource"
|
|
? new ExternalAccessTokenValidator(config)
|
|
: undefined;
|
|
|
|
return async (req: Request, res: Response, next: express.NextFunction) => {
|
|
if (!config.requireAuth) {
|
|
return next();
|
|
}
|
|
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
// Return 401 - the SDK's transport should detect this and throw an error
|
|
// For streamable-http, the SDK checks response status and throws StreamableHTTPError with code 401
|
|
res.status(401);
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.setHeader("WWW-Authenticate", "Bearer");
|
|
// Return a JSON-RPC error response format that the SDK will recognize
|
|
res.json({
|
|
jsonrpc: "2.0",
|
|
error: {
|
|
code: -32603,
|
|
message: "Unauthorized: Missing or invalid Bearer token (401)",
|
|
},
|
|
id: null,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const token = authHeader.substring(7); // Remove "Bearer " prefix
|
|
|
|
let valid = false;
|
|
let grantedScopes: string[] = [];
|
|
if (mode === "protected-resource") {
|
|
try {
|
|
const validated =
|
|
await externalValidator!.validateAccessTokenWithScopes(token);
|
|
valid = validated.valid;
|
|
grantedScopes = validated.scopes;
|
|
} catch {
|
|
valid = false;
|
|
}
|
|
} else {
|
|
valid = isValidToken(token);
|
|
if (valid) {
|
|
grantedScopes = getAccessTokenScopes(token);
|
|
}
|
|
}
|
|
|
|
if (!valid) {
|
|
// Return 401 - the SDK's transport should detect this and throw an error
|
|
res.status(401);
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.setHeader("WWW-Authenticate", "Bearer");
|
|
// Return a JSON-RPC error response format that the SDK will recognize
|
|
res.json({
|
|
jsonrpc: "2.0",
|
|
error: {
|
|
code: -32603,
|
|
message: "Unauthorized: Invalid or expired token (401)",
|
|
},
|
|
id: null,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Attach token info to request for use in handlers
|
|
const oauthReq = req as OAuthRequest;
|
|
oauthReq.oauthToken = token;
|
|
oauthReq.oauthTokenScopes = grantedScopes;
|
|
next();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set up OAuth metadata endpoints (RFC 8414)
|
|
*/
|
|
function setupMetadataEndpoints(
|
|
app: express.Application,
|
|
config: OAuthConfig,
|
|
): void {
|
|
const scopes = config.scopesSupported || ["mcp"];
|
|
const mode = getOAuthMode(config);
|
|
|
|
if (mode === "combined") {
|
|
// OAuth Authorization Server Metadata (local AS)
|
|
app.get(
|
|
"/.well-known/oauth-authorization-server",
|
|
(req: Request, res: Response) => {
|
|
const requestBaseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const actualIssuerUrl = config.issuerUrl ?? new URL(requestBaseUrl);
|
|
const metadata = {
|
|
// RFC 8414 §3.3: the issuer MUST be identical to the base URL the
|
|
// well-known path was appended to — i.e. no trailing slash. SDK v2's
|
|
// client enforces this exactly (IssuerMismatchError otherwise).
|
|
issuer: actualIssuerUrl.href.replace(/\/$/, ""),
|
|
authorization_endpoint: new URL("/oauth/authorize", actualIssuerUrl)
|
|
.href,
|
|
token_endpoint: new URL("/oauth/token", actualIssuerUrl).href,
|
|
scopes_supported: scopes,
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: [
|
|
"client_secret_basic",
|
|
"none",
|
|
],
|
|
...(config.supportDCR && {
|
|
registration_endpoint: new URL("/oauth/register", actualIssuerUrl)
|
|
.href,
|
|
}),
|
|
...(config.supportCIMD && {
|
|
client_id_metadata_document_supported: true,
|
|
}),
|
|
};
|
|
|
|
res.json(metadata);
|
|
},
|
|
);
|
|
}
|
|
|
|
// OAuth Protected Resource Metadata
|
|
app.get(
|
|
"/.well-known/oauth-protected-resource",
|
|
(req: Request, res: Response) => {
|
|
const requestBaseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const resourceUrl = config.resource ?? new URL("/", requestBaseUrl).href;
|
|
const localAsUrl = (
|
|
config.issuerUrl ?? new URL(requestBaseUrl)
|
|
).href.replace(/\/$/, "");
|
|
const authorizationServers =
|
|
mode === "protected-resource"
|
|
? (config.authorizationServers ?? [])
|
|
: [localAsUrl];
|
|
const metadata = {
|
|
resource: resourceUrl,
|
|
authorization_servers: authorizationServers.map((url) =>
|
|
url.replace(/\/$/, ""),
|
|
),
|
|
scopes_supported: scopes,
|
|
};
|
|
|
|
res.json(metadata);
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Set up OAuth authorization endpoint.
|
|
* Shows a simple consent page so users know they reached the test authorization server.
|
|
*/
|
|
function setupAuthorizationEndpoint(
|
|
app: express.Application,
|
|
config: OAuthConfig,
|
|
): void {
|
|
app.get("/oauth/authorize", async (req: Request, res: Response) => {
|
|
const parsed = await parseAuthorizationRequest(req.query, config);
|
|
if (!parsed.ok) {
|
|
res.status(parsed.status).json(parsed.body);
|
|
return;
|
|
}
|
|
|
|
res.type("html").send(renderOAuthConsentPage(parsed.value));
|
|
});
|
|
|
|
app.post(
|
|
"/oauth/authorize",
|
|
express.urlencoded({ extended: true }),
|
|
async (req: Request, res: Response) => {
|
|
const parsed = await parseAuthorizationRequest(req.body, config);
|
|
if (!parsed.ok) {
|
|
res.status(parsed.status).json(parsed.body);
|
|
return;
|
|
}
|
|
|
|
completeAuthorizationRedirect(res, parsed.value);
|
|
},
|
|
);
|
|
}
|
|
|
|
interface AuthorizationRequestParams {
|
|
clientId: string;
|
|
redirectUri: string;
|
|
responseType: string;
|
|
scope?: string;
|
|
state?: string;
|
|
codeChallenge?: string;
|
|
codeChallengeMethod?: string;
|
|
}
|
|
|
|
type AuthorizationRequestResult =
|
|
| { ok: true; value: AuthorizationRequestParams }
|
|
| { ok: false; status: number; body: Record<string, unknown> };
|
|
|
|
async function parseAuthorizationRequest(
|
|
input: Record<string, unknown>,
|
|
config: OAuthConfig,
|
|
): Promise<AuthorizationRequestResult> {
|
|
const client_id = input.client_id;
|
|
const redirect_uri = input.redirect_uri;
|
|
const response_type = input.response_type;
|
|
const scope = input.scope;
|
|
const state = input.state;
|
|
const code_challenge = input.code_challenge;
|
|
const code_challenge_method = input.code_challenge_method;
|
|
|
|
if (
|
|
typeof client_id !== "string" ||
|
|
typeof redirect_uri !== "string" ||
|
|
typeof response_type !== "string"
|
|
) {
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
body: {
|
|
error: "invalid_request",
|
|
error_description: "Missing required parameters",
|
|
},
|
|
};
|
|
}
|
|
|
|
if (response_type !== "code") {
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
body: { error: "unsupported_response_type" },
|
|
};
|
|
}
|
|
|
|
const client = await findClient(client_id, config);
|
|
if (!client) {
|
|
return { ok: false, status: 400, body: { error: "invalid_client" } };
|
|
}
|
|
|
|
if (client.redirectUris && !client.redirectUris.includes(redirect_uri)) {
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
body: {
|
|
error: "invalid_request",
|
|
error_description: "Invalid redirect_uri",
|
|
},
|
|
};
|
|
}
|
|
|
|
if (
|
|
typeof code_challenge_method === "string" &&
|
|
code_challenge_method !== "S256"
|
|
) {
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
body: {
|
|
error: "invalid_request",
|
|
error_description: "Unsupported code_challenge_method",
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
value: {
|
|
clientId: client_id,
|
|
redirectUri: redirect_uri,
|
|
responseType: response_type,
|
|
...(typeof scope === "string" ? { scope } : {}),
|
|
...(typeof state === "string" ? { state } : {}),
|
|
...(typeof code_challenge === "string"
|
|
? { codeChallenge: code_challenge }
|
|
: {}),
|
|
...(typeof code_challenge_method === "string"
|
|
? { codeChallengeMethod: code_challenge_method }
|
|
: {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function completeAuthorizationRedirect(
|
|
res: Response,
|
|
params: AuthorizationRequestParams,
|
|
): void {
|
|
const authCode = generateAuthorizationCode();
|
|
storeAuthorizationCode(authCode, {
|
|
clientId: params.clientId,
|
|
redirectUri: params.redirectUri,
|
|
codeChallenge: params.codeChallenge,
|
|
scope: params.scope,
|
|
});
|
|
|
|
const redirectUrl = new URL(params.redirectUri);
|
|
redirectUrl.searchParams.set("code", authCode);
|
|
if (params.state) {
|
|
redirectUrl.searchParams.set("state", params.state);
|
|
}
|
|
res.redirect(redirectUrl.href);
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function renderOAuthConsentPage(params: AuthorizationRequestParams): string {
|
|
const scopeList = parseScopeString(params.scope);
|
|
const scopeItems =
|
|
scopeList.length > 0
|
|
? scopeList
|
|
.map((scope) => `<li><code>${escapeHtml(scope)}</code></li>`)
|
|
.join("")
|
|
: "<li><em>No scopes requested</em></li>";
|
|
|
|
const hiddenFields = [
|
|
["client_id", params.clientId],
|
|
["redirect_uri", params.redirectUri],
|
|
["response_type", params.responseType],
|
|
...(params.scope ? [["scope", params.scope] as const] : []),
|
|
...(params.state ? [["state", params.state] as const] : []),
|
|
...(params.codeChallenge
|
|
? [["code_challenge", params.codeChallenge] as const]
|
|
: []),
|
|
...(params.codeChallengeMethod
|
|
? [["code_challenge_method", params.codeChallengeMethod] as const]
|
|
: []),
|
|
]
|
|
.map(
|
|
([name, value]) =>
|
|
`<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}" />`,
|
|
)
|
|
.join("\n");
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Authorize — MCP test server</title>
|
|
<style>
|
|
body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 32rem; line-height: 1.5; }
|
|
h1 { font-size: 1.25rem; }
|
|
code { background: #f4f4f5; padding: 0.1rem 0.25rem; border-radius: 0.25rem; }
|
|
ul { padding-left: 1.25rem; }
|
|
button { font: inherit; padding: 0.5rem 1rem; margin-right: 0.5rem; cursor: pointer; }
|
|
.primary { background: #228be6; color: white; border: 1px solid #1c7ed6; border-radius: 0.25rem; }
|
|
.muted { color: #666; font-size: 0.9rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Authorize MCP Inspector</h1>
|
|
<p class="muted">You were redirected to the <strong>local composable test authorization server</strong>.</p>
|
|
<p>Client: <code>${escapeHtml(params.clientId)}</code></p>
|
|
<p>Requested scopes:</p>
|
|
<ul>${scopeItems}</ul>
|
|
<form method="post" action="/oauth/authorize">
|
|
${hiddenFields}
|
|
<button type="submit" class="primary">Authorize</button>
|
|
</form>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
/**
|
|
* Set up OAuth token endpoint
|
|
*/
|
|
function setupTokenEndpoint(
|
|
app: express.Application,
|
|
config: OAuthConfig,
|
|
): void {
|
|
app.post(
|
|
"/oauth/token",
|
|
express.urlencoded({ extended: true }),
|
|
async (req: Request, res: Response) => {
|
|
const {
|
|
grant_type,
|
|
code,
|
|
redirect_uri,
|
|
client_id: bodyClientId,
|
|
code_verifier,
|
|
refresh_token,
|
|
} = req.body;
|
|
|
|
// Extract client_id from either body (client_secret_post) or Authorization header (client_secret_basic)
|
|
let client_id = bodyClientId;
|
|
let client_secret: string | undefined;
|
|
|
|
// Check Authorization header for client_secret_basic
|
|
const authHeader = req.headers.authorization;
|
|
if (authHeader && authHeader.startsWith("Basic ")) {
|
|
const credentials = Buffer.from(authHeader.slice(6), "base64").toString(
|
|
"utf-8",
|
|
);
|
|
const [id, secret] = credentials.split(":", 2);
|
|
client_id = id;
|
|
client_secret = secret;
|
|
}
|
|
|
|
if (grant_type === "authorization_code") {
|
|
// Authorization code flow
|
|
if (!code || !redirect_uri || !client_id) {
|
|
res.status(400).json({
|
|
error: "invalid_request",
|
|
error_description: "Missing required parameters",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const authCodeData = getAuthorizationCode(code);
|
|
if (!authCodeData) {
|
|
res.status(400).json({
|
|
error: "invalid_grant",
|
|
error_description: "Invalid or expired authorization code",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Verify client
|
|
const client = await findClient(client_id, config);
|
|
if (!client || client.clientId !== authCodeData.clientId) {
|
|
res.status(400).json({ error: "invalid_client" });
|
|
return;
|
|
}
|
|
|
|
// Verify client secret if provided (for client_secret_basic)
|
|
if (
|
|
client_secret &&
|
|
client.clientSecret &&
|
|
client.clientSecret !== client_secret
|
|
) {
|
|
res.status(400).json({ error: "invalid_client" });
|
|
return;
|
|
}
|
|
|
|
// Verify redirect_uri
|
|
if (authCodeData.redirectUri !== redirect_uri) {
|
|
res.status(400).json({
|
|
error: "invalid_grant",
|
|
error_description: "Redirect URI mismatch",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Verify PKCE code verifier
|
|
if (authCodeData.codeChallenge) {
|
|
if (!code_verifier) {
|
|
res.status(400).json({
|
|
error: "invalid_request",
|
|
error_description: "code_verifier required",
|
|
});
|
|
return;
|
|
}
|
|
// Proper PKCE verification: code_challenge should be base64url(SHA256(code_verifier))
|
|
const hash = crypto
|
|
.createHash("sha256")
|
|
.update(code_verifier)
|
|
.digest();
|
|
// Convert to base64url (replace + with -, / with _, remove padding)
|
|
const expectedChallenge = hash
|
|
.toString("base64")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=/g, "");
|
|
if (authCodeData.codeChallenge !== expectedChallenge) {
|
|
res.status(400).json({
|
|
error: "invalid_grant",
|
|
error_description: "Invalid code_verifier",
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Generate access token
|
|
const tokenScope =
|
|
authCodeData.scope || config.scopesSupported?.[0] || "mcp";
|
|
const accessToken = generateAccessToken(tokenScope);
|
|
const tokenExpiration = config.tokenExpirationSeconds || 3600;
|
|
|
|
const response: {
|
|
access_token: string;
|
|
token_type: string;
|
|
expires_in: number;
|
|
scope: string;
|
|
refresh_token?: string;
|
|
} = {
|
|
access_token: accessToken,
|
|
token_type: "Bearer",
|
|
expires_in: tokenExpiration,
|
|
scope: tokenScope,
|
|
};
|
|
|
|
// Add refresh token if supported
|
|
if (config.supportRefreshTokens !== false) {
|
|
const refreshToken = generateRefreshToken();
|
|
response.refresh_token = refreshToken;
|
|
storeRefreshToken(refreshToken, {
|
|
clientId: client_id,
|
|
scope: authCodeData.scope,
|
|
});
|
|
}
|
|
|
|
res.json(response);
|
|
} else if (grant_type === "refresh_token") {
|
|
// Refresh token flow
|
|
if (!refresh_token || !client_id) {
|
|
res.status(400).json({ error: "invalid_request" });
|
|
return;
|
|
}
|
|
|
|
const refreshTokenData = getRefreshToken(refresh_token);
|
|
if (!refreshTokenData || refreshTokenData.clientId !== client_id) {
|
|
res.status(400).json({ error: "invalid_grant" });
|
|
return;
|
|
}
|
|
|
|
const tokenScope =
|
|
refreshTokenData.scope || config.scopesSupported?.[0] || "mcp";
|
|
const accessToken = generateAccessToken(tokenScope);
|
|
const tokenExpiration = config.tokenExpirationSeconds || 3600;
|
|
|
|
res.json({
|
|
access_token: accessToken,
|
|
token_type: "Bearer",
|
|
expires_in: tokenExpiration,
|
|
scope: tokenScope,
|
|
});
|
|
} else {
|
|
res.status(400).json({ error: "unsupported_grant_type" });
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Set up Dynamic Client Registration endpoint
|
|
*/
|
|
function setupDCREndpoint(app: express.Application): void {
|
|
app.post("/oauth/register", express.json(), (req: Request, res: Response) => {
|
|
const { redirect_uris, client_name, scope } = req.body;
|
|
|
|
if (
|
|
!redirect_uris ||
|
|
!Array.isArray(redirect_uris) ||
|
|
redirect_uris.length === 0
|
|
) {
|
|
res.status(400).json({ error: "invalid_client_metadata" });
|
|
return;
|
|
}
|
|
|
|
dcrRequests.push({ redirect_uris: [...redirect_uris] });
|
|
|
|
// Generate client ID and secret
|
|
const clientId = generateClientId();
|
|
const clientSecret = generateClientSecret();
|
|
|
|
// Store registered client
|
|
registerClient(clientId, {
|
|
clientSecret,
|
|
redirectUris: redirect_uris,
|
|
clientName: client_name,
|
|
scope,
|
|
});
|
|
|
|
res.status(201).json({
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
redirect_uris,
|
|
...(client_name && { client_name }),
|
|
...(scope && { scope }),
|
|
});
|
|
});
|
|
}
|
|
|
|
// In-memory storage for test server (simplified - not production-ready)
|
|
interface AuthorizationCodeData {
|
|
clientId: string;
|
|
redirectUri: string;
|
|
codeChallenge?: string;
|
|
scope?: string;
|
|
expiresAt: number;
|
|
}
|
|
|
|
interface RefreshTokenData {
|
|
clientId: string;
|
|
scope?: string;
|
|
}
|
|
|
|
interface RegisteredClient {
|
|
clientSecret?: string;
|
|
redirectUris: string[];
|
|
clientName?: string;
|
|
scope?: string;
|
|
}
|
|
|
|
const authorizationCodes = new Map<string, AuthorizationCodeData>();
|
|
const accessTokens = new Set<string>();
|
|
/** Granted OAuth scope string per access token (space-separated). */
|
|
const accessTokenScopes = new Map<string, string>();
|
|
const refreshTokens = new Map<string, RefreshTokenData>();
|
|
const registeredClients = new Map<string, RegisteredClient>();
|
|
|
|
/** Recorded DCR request bodies (redirect_uris) for tests that verify both URLs are registered. */
|
|
const dcrRequests: Array<{ redirect_uris: string[] }> = [];
|
|
|
|
/**
|
|
* Check if a string is a valid URL
|
|
*/
|
|
function isUrl(str: string): boolean {
|
|
try {
|
|
new URL(str);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch client metadata document from URL (for CIMD)
|
|
*/
|
|
async function fetchClientMetadata(metadataUrl: string): Promise<{
|
|
redirect_uris: string[];
|
|
token_endpoint_auth_method?: string;
|
|
grant_types?: string[];
|
|
response_types?: string[];
|
|
client_name?: string;
|
|
client_uri?: string;
|
|
scope?: string;
|
|
} | null> {
|
|
try {
|
|
const response = await fetch(metadataUrl);
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
const metadata = await response.json();
|
|
return metadata;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function findClient(
|
|
clientId: string,
|
|
config: OAuthConfig,
|
|
): Promise<{
|
|
clientId: string;
|
|
clientSecret?: string;
|
|
redirectUris?: string[];
|
|
} | null> {
|
|
// Check static clients first
|
|
if (config.staticClients) {
|
|
const staticClient = config.staticClients.find(
|
|
(c) => c.clientId === clientId,
|
|
);
|
|
if (staticClient) {
|
|
return {
|
|
clientId: staticClient.clientId,
|
|
clientSecret: staticClient.clientSecret,
|
|
redirectUris: staticClient.redirectUris,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Check registered clients (DCR)
|
|
if (registeredClients.has(clientId)) {
|
|
const client = registeredClients.get(clientId)!;
|
|
return {
|
|
clientId,
|
|
clientSecret: client.clientSecret,
|
|
redirectUris: client.redirectUris,
|
|
};
|
|
}
|
|
|
|
// Check CIMD: if client_id is a URL and CIMD is supported, fetch metadata
|
|
if (config.supportCIMD && isUrl(clientId)) {
|
|
const metadata = await fetchClientMetadata(clientId);
|
|
if (
|
|
metadata &&
|
|
metadata.redirect_uris &&
|
|
Array.isArray(metadata.redirect_uris)
|
|
) {
|
|
// For CIMD, the client_id is the URL itself, and there's no client_secret
|
|
// (CIMD uses token_endpoint_auth_method: "none" typically)
|
|
return {
|
|
clientId, // The URL is the client_id
|
|
clientSecret: undefined, // CIMD typically doesn't use secrets
|
|
redirectUris: metadata.redirect_uris,
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function generateAuthorizationCode(): string {
|
|
return `test_auth_code_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
}
|
|
|
|
function storeAuthorizationCode(
|
|
code: string,
|
|
data: Omit<AuthorizationCodeData, "expiresAt">,
|
|
): void {
|
|
authorizationCodes.set(code, {
|
|
...data,
|
|
expiresAt: Date.now() + 60000, // 1 minute expiration
|
|
});
|
|
}
|
|
|
|
function getAuthorizationCode(code: string): AuthorizationCodeData | null {
|
|
const data = authorizationCodes.get(code);
|
|
if (!data) {
|
|
return null;
|
|
}
|
|
|
|
// Check expiration
|
|
if (Date.now() > data.expiresAt) {
|
|
authorizationCodes.delete(code);
|
|
return null;
|
|
}
|
|
|
|
// Delete after use (authorization codes are single-use)
|
|
authorizationCodes.delete(code);
|
|
return data;
|
|
}
|
|
|
|
function generateAccessToken(scope?: string): string {
|
|
const token = `test_access_token_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
accessTokens.add(token);
|
|
accessTokenScopes.set(token, scope?.trim() || "mcp");
|
|
return token;
|
|
}
|
|
|
|
function generateRefreshToken(): string {
|
|
return `test_refresh_token_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
}
|
|
|
|
function storeRefreshToken(token: string, data: RefreshTokenData): void {
|
|
refreshTokens.set(token, data);
|
|
}
|
|
|
|
function getRefreshToken(token: string): RefreshTokenData | null {
|
|
return refreshTokens.get(token) || null;
|
|
}
|
|
|
|
function generateClientId(): string {
|
|
return `test_client_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
}
|
|
|
|
function generateClientSecret(): string {
|
|
return `test_secret_${Math.random().toString(36).substring(2, 15)}`;
|
|
}
|
|
|
|
function registerClient(clientId: string, client: RegisteredClient): void {
|
|
registeredClients.set(clientId, client);
|
|
}
|
|
|
|
function isValidToken(token: string): boolean {
|
|
// Simplified token validation for test server
|
|
// In production, verify JWT signature, expiration, etc.
|
|
return accessTokens.has(token);
|
|
}
|
|
|
|
function parseScopeString(scope: string | undefined): string[] {
|
|
if (!scope?.trim()) {
|
|
return [];
|
|
}
|
|
return scope.trim().split(/\s+/).filter(Boolean);
|
|
}
|
|
|
|
/** Test helper: mint an access token with the given granted scopes. */
|
|
export function mintTestAccessToken(scope: string): string {
|
|
return generateAccessToken(scope);
|
|
}
|
|
|
|
/** Granted scopes for a test-server access token (empty when unknown). */
|
|
export function getAccessTokenScopes(token: string): string[] {
|
|
return parseScopeString(accessTokenScopes.get(token));
|
|
}
|
|
|
|
export interface ScopeRequirementRegistry {
|
|
tools: Map<string, string[]>;
|
|
resources: Map<string, string[]>;
|
|
prompts: Map<string, string[]>;
|
|
resourceTemplates: Map<string, string[]>;
|
|
}
|
|
|
|
function resourceUriMatchesTemplate(uri: string, uriTemplate: string): boolean {
|
|
const brace = uriTemplate.indexOf("{");
|
|
const prefix = brace >= 0 ? uriTemplate.slice(0, brace) : uriTemplate;
|
|
return uri.startsWith(prefix);
|
|
}
|
|
|
|
/** Build lookup tables from merged ServerConfig capability definitions. */
|
|
export function buildScopeRequirementRegistry(
|
|
config: ServerConfig,
|
|
): ScopeRequirementRegistry {
|
|
const registry: ScopeRequirementRegistry = {
|
|
tools: new Map(),
|
|
resources: new Map(),
|
|
prompts: new Map(),
|
|
resourceTemplates: new Map(),
|
|
};
|
|
|
|
for (const tool of config.tools ?? []) {
|
|
if (tool.requiredScopes?.length) {
|
|
registry.tools.set(tool.name, tool.requiredScopes);
|
|
}
|
|
}
|
|
for (const resource of config.resources ?? []) {
|
|
if (resource.requiredScopes?.length) {
|
|
registry.resources.set(resource.uri, resource.requiredScopes);
|
|
}
|
|
}
|
|
for (const prompt of config.prompts ?? []) {
|
|
if (prompt.requiredScopes?.length) {
|
|
registry.prompts.set(prompt.name, prompt.requiredScopes);
|
|
}
|
|
}
|
|
for (const template of config.resourceTemplates ?? []) {
|
|
if (template.requiredScopes?.length) {
|
|
registry.resourceTemplates.set(
|
|
template.uriTemplate,
|
|
template.requiredScopes,
|
|
);
|
|
}
|
|
}
|
|
|
|
return registry;
|
|
}
|
|
|
|
export function scopeRequirementRegistryHasEntries(
|
|
registry: ScopeRequirementRegistry,
|
|
): boolean {
|
|
return (
|
|
registry.tools.size > 0 ||
|
|
registry.resources.size > 0 ||
|
|
registry.prompts.size > 0 ||
|
|
registry.resourceTemplates.size > 0
|
|
);
|
|
}
|
|
|
|
function parseMcpOperation(body: unknown): {
|
|
method?: string;
|
|
target?: string;
|
|
} {
|
|
if (!body || typeof body !== "object") {
|
|
return {};
|
|
}
|
|
const rpc = body as Record<string, unknown>;
|
|
const method = typeof rpc.method === "string" ? rpc.method : undefined;
|
|
const params =
|
|
rpc.params && typeof rpc.params === "object"
|
|
? (rpc.params as Record<string, unknown>)
|
|
: undefined;
|
|
|
|
if (method === "tools/call" && typeof params?.name === "string") {
|
|
return { method, target: params.name };
|
|
}
|
|
if (method === "resources/read" && typeof params?.uri === "string") {
|
|
return { method, target: params.uri };
|
|
}
|
|
if (method === "prompts/get" && typeof params?.name === "string") {
|
|
return { method, target: params.name };
|
|
}
|
|
|
|
return { method };
|
|
}
|
|
|
|
function tokenHasRequiredScopes(
|
|
granted: string[],
|
|
required: string[],
|
|
): boolean {
|
|
const grantedSet = new Set(granted);
|
|
return required.every((scope) => grantedSet.has(scope));
|
|
}
|
|
|
|
/**
|
|
* Enforce per-capability OAuth scopes after bearer validation.
|
|
* Returns 403 + insufficient_scope when the token is valid but lacks scope.
|
|
*/
|
|
export function createScopeCheckMiddleware(
|
|
registry: ScopeRequirementRegistry,
|
|
): express.RequestHandler {
|
|
return (req: Request, res: Response, next: express.NextFunction) => {
|
|
const oauthReq = req as OAuthRequest;
|
|
const token = oauthReq.oauthToken;
|
|
if (!token) {
|
|
return next();
|
|
}
|
|
|
|
const { method, target } = parseMcpOperation(req.body);
|
|
if (!method || !target) {
|
|
return next();
|
|
}
|
|
|
|
let requiredScopes: string[] | undefined;
|
|
if (method === "tools/call") {
|
|
requiredScopes = registry.tools.get(target);
|
|
} else if (method === "resources/read") {
|
|
requiredScopes = registry.resources.get(target);
|
|
if (!requiredScopes?.length) {
|
|
for (const [uriTemplate, scopes] of registry.resourceTemplates) {
|
|
if (resourceUriMatchesTemplate(target, uriTemplate)) {
|
|
requiredScopes = scopes;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} else if (method === "prompts/get") {
|
|
requiredScopes = registry.prompts.get(target);
|
|
}
|
|
|
|
if (!requiredScopes?.length) {
|
|
return next();
|
|
}
|
|
|
|
const granted = oauthReq.oauthTokenScopes ?? getAccessTokenScopes(token);
|
|
if (tokenHasRequiredScopes(granted, requiredScopes)) {
|
|
return next();
|
|
}
|
|
|
|
const grantedSet = new Set(granted);
|
|
const missingScopes = requiredScopes.filter(
|
|
(scope) => !grantedSet.has(scope),
|
|
);
|
|
const scopeHeader = missingScopes.join(" ") || requiredScopes.join(" ");
|
|
res.status(403);
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.setHeader(
|
|
"WWW-Authenticate",
|
|
`Bearer error="insufficient_scope", scope="${scopeHeader}"`,
|
|
);
|
|
res.json({
|
|
jsonrpc: "2.0",
|
|
error: {
|
|
code: -32603,
|
|
message: `Forbidden: insufficient scope (403). Required: ${scopeHeader}`,
|
|
},
|
|
id: null,
|
|
});
|
|
return;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear all OAuth test data (useful for test cleanup)
|
|
*/
|
|
export function clearOAuthTestData(): void {
|
|
authorizationCodes.clear();
|
|
accessTokens.clear();
|
|
accessTokenScopes.clear();
|
|
refreshTokens.clear();
|
|
registeredClients.clear();
|
|
dcrRequests.length = 0;
|
|
}
|
|
|
|
/**
|
|
* Returns recorded DCR request bodies (redirect_uris) for tests that verify
|
|
* redirect URI registration.
|
|
*/
|
|
export function getDCRRequests(): Array<{ redirect_uris: string[] }> {
|
|
return dcrRequests;
|
|
}
|
|
|
|
/**
|
|
* Invalidate a single access token (remove from valid set).
|
|
* Used by E2E tests to simulate expired/revoked access token while keeping
|
|
* refresh_token valid, so 401 → auth() → refresh → retry can be exercised.
|
|
*/
|
|
export function invalidateAccessToken(token: string): void {
|
|
accessTokens.delete(token);
|
|
accessTokenScopes.delete(token);
|
|
}
|