Merge remote-tracking branch 'origin/v2/main' into 1579-wave-1
# Conflicts: # AGENTS.md # clients/cli/README.md # clients/web/src/App.test.tsx # clients/web/src/App.tsx # clients/web/src/test/core/auth/utils.test.ts # core/mcp/node/transport.ts # test-servers/src/composable-test-server.ts
This commit is contained in:
commit
cfbdfdf2bd
8 changed files with 15921 additions and 1202 deletions
|
|
@ -100,6 +100,8 @@ export interface ToolDefinition {
|
|||
name: string;
|
||||
description: string;
|
||||
inputSchema?: ToolInputSchema;
|
||||
/** OAuth scopes required to invoke this tool (enforced at HTTP layer). */
|
||||
requiredScopes?: string[];
|
||||
/** Optional Zod object schema for tool output; when set, handler must return structuredContent. */
|
||||
outputSchema?: unknown;
|
||||
/** Passed through to the SDK so clients can read tool-level `_meta` (e.g. `_meta.ui.resourceUri` for MCP App tools). */
|
||||
|
|
@ -115,6 +117,8 @@ export interface TaskToolDefinition {
|
|||
name: string;
|
||||
description: string;
|
||||
inputSchema?: ToolInputSchema;
|
||||
/** OAuth scopes required to invoke this tool (enforced at HTTP layer). */
|
||||
requiredScopes?: string[];
|
||||
execution?: { taskSupport: "required" | "optional" };
|
||||
/** Passed through to the SDK so clients can read tool-level `_meta` (e.g. `_meta.ui.resourceUri` for an App-flavored task tool). Mirrors {@link ToolDefinition._meta}. */
|
||||
_meta?: Record<string, unknown>;
|
||||
|
|
@ -132,11 +136,15 @@ export interface ResourceDefinition {
|
|||
* Only the default read handler applies this; a `customHandler` from `config.onRegisterResource` replaces the `contents` wholesale, so such a handler must re-add `_meta` itself if the resource needs it on the read response.
|
||||
*/
|
||||
_meta?: Record<string, unknown>;
|
||||
/** OAuth scopes required to read this resource (enforced at HTTP layer). */
|
||||
requiredScopes?: string[];
|
||||
}
|
||||
|
||||
export interface PromptDefinition {
|
||||
name: string;
|
||||
description?: string;
|
||||
/** OAuth scopes required to fetch this prompt (enforced at HTTP layer). */
|
||||
requiredScopes?: string[];
|
||||
promptString: string; // The prompt text with optional {argName} placeholders
|
||||
argsSchema?: PromptArgsSchema; // Can include completable() schemas
|
||||
// Optional completion callbacks keyed by argument name
|
||||
|
|
@ -154,6 +162,8 @@ export interface ResourceTemplateDefinition {
|
|||
name: string;
|
||||
uriTemplate: string; // URI template with {variable} placeholders (RFC 6570)
|
||||
description?: string;
|
||||
/** OAuth scopes required to read resources from this template (enforced at HTTP layer). */
|
||||
requiredScopes?: string[];
|
||||
inputSchema?: ZodRawShapeCompat; // Schema for template variables
|
||||
handler: (
|
||||
uri: URL,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import YAML from "yaml";
|
|||
export interface PresetRef {
|
||||
preset: string;
|
||||
params?: Record<string, unknown>;
|
||||
/** OAuth scopes the bearer token must include to use this capability. */
|
||||
requiredScopes?: string[];
|
||||
}
|
||||
|
||||
export interface ConfigFileOAuth {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
} from "./composable-test-server.js";
|
||||
import {
|
||||
createEchoTool,
|
||||
createGetEnvTool,
|
||||
createAddTool,
|
||||
createGetSumTool,
|
||||
createWriteToStderrTool,
|
||||
|
|
@ -80,6 +81,9 @@ function resolveToolPreset(
|
|||
switch (name) {
|
||||
case "echo":
|
||||
return createEchoTool();
|
||||
case "get-env":
|
||||
case "get_env":
|
||||
return createGetEnvTool();
|
||||
case "add":
|
||||
return createAddTool();
|
||||
case "get_sum":
|
||||
|
|
|
|||
|
|
@ -14,12 +14,22 @@ import { createTestServerInfo } from "./test-server-fixtures.js";
|
|||
import { resolvePreset } from "./preset-registry.js";
|
||||
import type { ConfigFile, PresetRef } from "./load-config.js";
|
||||
|
||||
function resolvePresetRefs<T>(
|
||||
function mergeRequiredScopes<T extends { requiredScopes?: string[] }>(
|
||||
item: T,
|
||||
ref: PresetRef,
|
||||
): T {
|
||||
if (!ref.requiredScopes?.length) {
|
||||
return item;
|
||||
}
|
||||
return { ...item, requiredScopes: ref.requiredScopes };
|
||||
}
|
||||
|
||||
function resolvePresetRefs<T extends { requiredScopes?: string[] }>(
|
||||
refs: Array<PresetRef | PresetRef[]> | undefined,
|
||||
type: "tool" | "resource" | "resourceTemplate" | "prompt",
|
||||
): T[] {
|
||||
if (!refs || refs.length === 0) return [];
|
||||
const result: T[] = [];
|
||||
const result: Array<{ requiredScopes?: string[] }> = [];
|
||||
for (const entry of refs) {
|
||||
const items = Array.isArray(entry) ? entry : [entry];
|
||||
for (const ref of items) {
|
||||
|
|
@ -31,10 +41,14 @@ function resolvePresetRefs<T>(
|
|||
}
|
||||
const resolved = resolvePreset(type, presetName, ref.params);
|
||||
const arr = Array.isArray(resolved) ? resolved : [resolved];
|
||||
result.push(...(arr as T[]));
|
||||
for (const item of arr) {
|
||||
result.push(mergeRequiredScopes(item, ref));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
// The caller pairs `type` with the matching `T`, so each resolved preset is
|
||||
// the requested definition kind; TS can't see that correspondence.
|
||||
return result as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -169,6 +169,22 @@ export function createEchoTool(): ToolDefinition {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a "get-env" tool matching @modelcontextprotocol/server-everything.
|
||||
* Returns the server process environment as pretty-printed JSON text.
|
||||
*/
|
||||
export function createGetEnvTool(): ToolDefinition {
|
||||
return {
|
||||
name: "get-env",
|
||||
description:
|
||||
"Returns all environment variables, helpful for debugging MCP server configuration",
|
||||
inputSchema: {},
|
||||
handler: async () => {
|
||||
return toToolResult(JSON.stringify(process.env, null, 2));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tool that writes a message to stderr. Used to test stderr capture/piping.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import type { ServerConfig } from "./test-server-fixtures.js";
|
|||
import {
|
||||
setupOAuthRoutes,
|
||||
createBearerTokenMiddleware,
|
||||
buildScopeRequirementRegistry,
|
||||
scopeRequirementRegistryHasEntries,
|
||||
createScopeCheckMiddleware,
|
||||
} from "./test-server-oauth.js";
|
||||
import {
|
||||
setTestServerControl,
|
||||
|
|
@ -195,6 +198,10 @@ export class TestServerHttp {
|
|||
const mcpMiddleware: express.RequestHandler[] = [];
|
||||
if (this.config.oauth?.enabled && this.config.oauth.requireAuth) {
|
||||
mcpMiddleware.push(createBearerTokenMiddleware(this.config.oauth));
|
||||
const scopeRegistry = buildScopeRequirementRegistry(this.config);
|
||||
if (scopeRequirementRegistryHasEntries(scopeRegistry)) {
|
||||
mcpMiddleware.push(createScopeCheckMiddleware(scopeRegistry));
|
||||
}
|
||||
}
|
||||
|
||||
// Set up Express route to handle MCP requests
|
||||
|
|
@ -328,6 +335,10 @@ export class TestServerHttp {
|
|||
const sseMiddleware: express.RequestHandler[] = [];
|
||||
if (this.config.oauth?.enabled && this.config.oauth.requireAuth) {
|
||||
sseMiddleware.push(createBearerTokenMiddleware(this.config.oauth));
|
||||
const scopeRegistry = buildScopeRequirementRegistry(this.config);
|
||||
if (scopeRequirementRegistryHasEntries(scopeRegistry)) {
|
||||
sseMiddleware.push(createScopeCheckMiddleware(scopeRegistry));
|
||||
}
|
||||
}
|
||||
|
||||
// One McpServer per connection (same pattern as streamable-http)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,34 @@ function audienceMatches(aud: unknown, expected: string): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
function parseScopeString(scope: string | undefined): string[] {
|
||||
if (!scope?.trim()) {
|
||||
return [];
|
||||
}
|
||||
return scope.trim().split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Extract granted OAuth scopes from a verified JWT access token payload. */
|
||||
export function extractScopesFromJwtPayload(
|
||||
payload: Record<string, unknown>,
|
||||
): string[] {
|
||||
if (typeof payload.scope === "string") {
|
||||
return parseScopeString(payload.scope);
|
||||
}
|
||||
if (Array.isArray(payload.scp)) {
|
||||
return payload.scp.filter((s): s is string => typeof s === "string");
|
||||
}
|
||||
if (Array.isArray(payload.scopes)) {
|
||||
return payload.scopes.filter((s): s is string => typeof s === "string");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export interface ValidatedAccessToken {
|
||||
valid: boolean;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export class ExternalAccessTokenValidator {
|
||||
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
private allowedIssuers = new Set<string>();
|
||||
|
|
@ -97,11 +125,15 @@ export class ExternalAccessTokenValidator {
|
|||
});
|
||||
}
|
||||
|
||||
async validateAccessToken(token: string): Promise<boolean> {
|
||||
async validateAccessTokenWithScopes(
|
||||
token: string,
|
||||
): Promise<ValidatedAccessToken> {
|
||||
if (!this.jwks) {
|
||||
await this.ensureReady();
|
||||
}
|
||||
if (!this.jwks) return false;
|
||||
if (!this.jwks) {
|
||||
return { valid: false, scopes: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, this.jwks, {
|
||||
|
|
@ -111,13 +143,20 @@ export class ExternalAccessTokenValidator {
|
|||
if (this.config.resourceAudience) {
|
||||
const aud = payload.aud;
|
||||
if (!audienceMatches(aud, this.config.resourceAudience)) {
|
||||
return false;
|
||||
return { valid: false, scopes: [] };
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return {
|
||||
valid: true,
|
||||
scopes: extractScopesFromJwtPayload(payload as Record<string, unknown>),
|
||||
};
|
||||
} catch {
|
||||
return false;
|
||||
return { valid: false, scopes: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async validateAccessToken(token: string): Promise<boolean> {
|
||||
return (await this.validateAccessTokenWithScopes(token)).valid;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ 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
|
||||
*/
|
||||
|
|
@ -87,14 +92,21 @@ export function createBearerTokenMiddleware(
|
|||
const token = authHeader.substring(7); // Remove "Bearer " prefix
|
||||
|
||||
let valid = false;
|
||||
let grantedScopes: string[] = [];
|
||||
if (mode === "protected-resource") {
|
||||
try {
|
||||
valid = await externalValidator!.validateAccessToken(token);
|
||||
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) {
|
||||
|
|
@ -115,7 +127,9 @@ export function createBearerTokenMiddleware(
|
|||
}
|
||||
|
||||
// Attach token info to request for use in handlers
|
||||
(req as Request & { oauthToken?: string }).oauthToken = token;
|
||||
const oauthReq = req as OAuthRequest;
|
||||
oauthReq.oauthToken = token;
|
||||
oauthReq.oauthTokenScopes = grantedScopes;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
|
@ -190,86 +204,218 @@ function setupMetadataEndpoints(
|
|||
}
|
||||
|
||||
/**
|
||||
* Set up OAuth authorization endpoint
|
||||
* For test servers, this auto-approves requests and redirects with authorization code
|
||||
* 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 {
|
||||
client_id,
|
||||
redirect_uri,
|
||||
response_type,
|
||||
scope,
|
||||
state,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
} = req.query;
|
||||
const parsed = await parseAuthorizationRequest(req.query, config);
|
||||
if (!parsed.ok) {
|
||||
res.status(parsed.status).json(parsed.body);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!client_id || !redirect_uri || !response_type) {
|
||||
res.status(400).json({
|
||||
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",
|
||||
});
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (response_type !== "code") {
|
||||
res.status(400).json({ error: "unsupported_response_type" });
|
||||
return;
|
||||
}
|
||||
if (response_type !== "code") {
|
||||
return { ok: false, status: 400, body: { error: "unsupported_response_type" } };
|
||||
}
|
||||
|
||||
// Validate client (check static clients, DCR, or CIMD)
|
||||
const client = await findClient(client_id as string, config);
|
||||
if (!client) {
|
||||
res.status(400).json({ error: "invalid_client" });
|
||||
return;
|
||||
}
|
||||
const client = await findClient(client_id, config);
|
||||
if (!client) {
|
||||
return { ok: false, status: 400, body: { error: "invalid_client" } };
|
||||
}
|
||||
|
||||
// Validate redirect_uri
|
||||
if (
|
||||
client.redirectUris &&
|
||||
!client.redirectUris.includes(redirect_uri as string)
|
||||
) {
|
||||
res.status(400).json({
|
||||
if (
|
||||
client.redirectUris &&
|
||||
!client.redirectUris.includes(redirect_uri)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
body: {
|
||||
error: "invalid_request",
|
||||
error_description: "Invalid redirect_uri",
|
||||
});
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Validate PKCE
|
||||
if (code_challenge_method && code_challenge_method !== "S256") {
|
||||
res.status(400).json({
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// For test servers, auto-approve and generate authorization code
|
||||
const authCode = generateAuthorizationCode();
|
||||
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 }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Store authorization code temporarily (in production, use proper storage)
|
||||
storeAuthorizationCode(authCode, {
|
||||
clientId: client_id as string,
|
||||
redirectUri: redirect_uri as string,
|
||||
codeChallenge: code_challenge as string | undefined,
|
||||
scope: scope as string | undefined,
|
||||
});
|
||||
|
||||
// Redirect with authorization code
|
||||
const redirectUrl = new URL(redirect_uri as string);
|
||||
redirectUrl.searchParams.set("code", authCode);
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set("state", state as string);
|
||||
}
|
||||
|
||||
res.redirect(redirectUrl.href);
|
||||
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>`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -382,7 +528,9 @@ function setupTokenEndpoint(
|
|||
}
|
||||
|
||||
// Generate access token
|
||||
const accessToken = generateAccessToken();
|
||||
const tokenScope =
|
||||
authCodeData.scope || config.scopesSupported?.[0] || "mcp";
|
||||
const accessToken = generateAccessToken(tokenScope);
|
||||
const tokenExpiration = config.tokenExpirationSeconds || 3600;
|
||||
|
||||
const response: {
|
||||
|
|
@ -395,7 +543,7 @@ function setupTokenEndpoint(
|
|||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: tokenExpiration,
|
||||
scope: authCodeData.scope || config.scopesSupported?.[0] || "mcp",
|
||||
scope: tokenScope,
|
||||
};
|
||||
|
||||
// Add refresh token if supported
|
||||
|
|
@ -422,14 +570,16 @@ function setupTokenEndpoint(
|
|||
return;
|
||||
}
|
||||
|
||||
const accessToken = generateAccessToken();
|
||||
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: refreshTokenData.scope || config.scopesSupported?.[0] || "mcp",
|
||||
scope: tokenScope,
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({ error: "unsupported_grant_type" });
|
||||
|
|
@ -501,6 +651,8 @@ interface RegisteredClient {
|
|||
|
||||
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>();
|
||||
|
||||
|
|
@ -627,9 +779,10 @@ function getAuthorizationCode(code: string): AuthorizationCodeData | null {
|
|||
return data;
|
||||
}
|
||||
|
||||
function generateAccessToken(): string {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -663,12 +816,191 @@ function isValidToken(token: string): boolean {
|
|||
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;
|
||||
|
|
@ -689,4 +1021,5 @@ export function getDCRRequests(): Array<{ redirect_uris: string[] }> {
|
|||
*/
|
||||
export function invalidateAccessToken(token: string): void {
|
||||
accessTokens.delete(token);
|
||||
accessTokenScopes.delete(token);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue