Merge remote-tracking branch 'origin/v2/main' into v2/fix/2172-oidc-discovery-compat
Signed-off-by: cliffhall <cliff@futurescale.com> # Conflicts: # README.md # core/mcp/inspectorClient.ts # test-servers/src/test-server-oauth.ts
This commit is contained in:
commit
2eeab05d00
9 changed files with 8524 additions and 1598 deletions
12
test-servers/configs/cancellation-modern-http.json
Normal file
12
test-servers/configs/cancellation-modern-http.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"serverInfo": {
|
||||
"name": "cancellation-showcase",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"tools": [{ "preset": "slow_task" }, { "preset": "echo" }],
|
||||
"transport": {
|
||||
"type": "streamable-http",
|
||||
"port": 6606,
|
||||
"modern": true
|
||||
}
|
||||
}
|
||||
20
test-servers/configs/oauth-no-revocation-http.json
Normal file
20
test-servers/configs/oauth-no-revocation-http.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"serverInfo": {
|
||||
"name": "oauth-no-revocation",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"tools": [{ "preset": "echo" }],
|
||||
"oauth": {
|
||||
"enabled": true,
|
||||
"mode": "combined",
|
||||
"requireAuth": true,
|
||||
"scopesSupported": ["mcp"],
|
||||
"supportDCR": true,
|
||||
"supportRefreshTokens": true,
|
||||
"supportRevocation": false
|
||||
},
|
||||
"transport": {
|
||||
"type": "streamable-http",
|
||||
"port": 8084
|
||||
}
|
||||
}
|
||||
20
test-servers/configs/oauth-revocation-http.json
Normal file
20
test-servers/configs/oauth-revocation-http.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"serverInfo": {
|
||||
"name": "oauth-revocation",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"tools": [{ "preset": "echo" }],
|
||||
"oauth": {
|
||||
"enabled": true,
|
||||
"mode": "combined",
|
||||
"requireAuth": true,
|
||||
"scopesSupported": ["mcp"],
|
||||
"supportDCR": true,
|
||||
"supportRefreshTokens": true,
|
||||
"supportRevocation": true
|
||||
},
|
||||
"transport": {
|
||||
"type": "streamable-http",
|
||||
"port": 8083
|
||||
}
|
||||
}
|
||||
|
|
@ -685,6 +685,14 @@ export interface ServerConfig {
|
|||
* Whether to support refresh tokens (default: true)
|
||||
*/
|
||||
supportRefreshTokens?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to advertise and serve the RFC 7009 `revocation_endpoint`
|
||||
* (default: true). Set to `false` to reproduce an authorization server that
|
||||
* offers no revocation, where the Inspector must send nothing and clear
|
||||
* local state exactly as it always has. (#2144)
|
||||
*/
|
||||
supportRevocation?: boolean;
|
||||
};
|
||||
/**
|
||||
* Serve the modern (2026-07-28) protocol era via the SDK's
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export interface ConfigFileOAuth {
|
|||
supportCIMD?: boolean;
|
||||
tokenExpirationSeconds?: number;
|
||||
supportRefreshTokens?: boolean;
|
||||
/** RFC 7009 revocation endpoint; default true (#2144). */
|
||||
supportRevocation?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigFile {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
createCollectUrlElicitationTool,
|
||||
createUrlElicitationFormTool,
|
||||
createSendNotificationTool,
|
||||
createSlowTaskTool,
|
||||
createGetAnnotatedMessageTool,
|
||||
createGetTempTool,
|
||||
createGetTempExtraTool,
|
||||
|
|
@ -165,6 +166,8 @@ function resolveToolPreset(
|
|||
return createUrlElicitationFormTool();
|
||||
case "send_notification":
|
||||
return createSendNotificationTool();
|
||||
case "slow_task":
|
||||
return createSlowTaskTool();
|
||||
case "get_annotated_message":
|
||||
return createGetAnnotatedMessageTool();
|
||||
case "get_temp":
|
||||
|
|
|
|||
|
|
@ -1219,6 +1219,98 @@ export function createCollectUrlElicitationTool(): ToolDefinition {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep `ms`, resolving `false` the moment `signal` aborts instead.
|
||||
*
|
||||
* Both listeners are removed on whichever outcome wins. A caller that sleeps in
|
||||
* a loop would otherwise leave one attached per iteration — `{ once: true }`
|
||||
* detaches a listener when the event *fires*, not when the waiter loses
|
||||
* interest — and enough of them trip Node's `MaxListenersExceededWarning`.
|
||||
*/
|
||||
function sleepUnlessAborted(
|
||||
ms: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
if (signal?.aborted) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(true);
|
||||
}, ms);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a "slow_task" tool that runs until the client cancels it (#2140).
|
||||
*
|
||||
* It emits a progress notification every second up to `seconds` (default 60)
|
||||
* and completes only if it is never cancelled, so there is a long, visible
|
||||
* window in which to click Cancel.
|
||||
*
|
||||
* The point is what it does on the way out. On the 2026-07-28 era a client
|
||||
* cancels a Streamable HTTP request by closing that request's response stream,
|
||||
* and the SDK surfaces the disconnect to this handler as `extra.signal`. So the
|
||||
* tool stops the moment the stream closes; cancelled by a
|
||||
* `notifications/cancelled` the server is free to ignore, it keeps emitting
|
||||
* progress until it completes on its own — the exact symptom reported.
|
||||
*
|
||||
* Both outcomes are written to **stderr**, because that is the only channel
|
||||
* they can reach. A cancellation closes the very stream this handler's result
|
||||
* would travel on, so its return value is undeliverable by construction: the
|
||||
* server's terminal is where you watch this, not the Inspector's result panel.
|
||||
*/
|
||||
export function createSlowTaskTool(): ToolDefinition {
|
||||
return {
|
||||
name: "slow_task",
|
||||
description:
|
||||
"Run for up to `seconds`, reporting progress each second, until cancelled",
|
||||
inputSchema: {
|
||||
seconds: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(600)
|
||||
.optional()
|
||||
.describe("How long to run before completing on its own (default 60)"),
|
||||
},
|
||||
handler: async (
|
||||
params: Record<string, unknown>,
|
||||
_context?: TestServerContext,
|
||||
extra?: HandlerExtra,
|
||||
) => {
|
||||
const total = typeof params.seconds === "number" ? params.seconds : 60;
|
||||
const progressToken = extra?._meta?.progressToken;
|
||||
const signal = extra?.signal;
|
||||
for (let step = 1; step <= total; step++) {
|
||||
if (!(await sleepUnlessAborted(1000, signal))) {
|
||||
// Cancelled. Return rather than keep working — the whole point of
|
||||
// the demo is that the work actually stops.
|
||||
const message = `[slow_task] cancelled after ${step - 1}s`;
|
||||
console.error(message);
|
||||
return toToolResult(message);
|
||||
}
|
||||
if (progressToken !== undefined) {
|
||||
await extra?.sendNotification?.({
|
||||
method: "notifications/progress",
|
||||
params: { progressToken, progress: step, total },
|
||||
});
|
||||
}
|
||||
}
|
||||
const message = `[slow_task] completed all ${total}s without being cancelled`;
|
||||
console.error(message);
|
||||
return toToolResult(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a "send_notification" tool that sends a notification message from the server
|
||||
*/
|
||||
|
|
@ -2910,6 +3002,8 @@ export function createOAuthTestServerConfig(options: {
|
|||
supportCIMD?: boolean;
|
||||
tokenExpirationSeconds?: number;
|
||||
supportRefreshTokens?: boolean;
|
||||
/** RFC 7009 revocation endpoint; default true (#2144). */
|
||||
supportRevocation?: boolean;
|
||||
/**
|
||||
* Move the RFC 9728 metadata document off the well-known path and advertise
|
||||
* it via `WWW-Authenticate: Bearer resource_metadata="…"` (#2071).
|
||||
|
|
@ -2945,6 +3039,7 @@ export function createOAuthTestServerConfig(options: {
|
|||
supportCIMD: options.supportCIMD ?? false,
|
||||
tokenExpirationSeconds: options.tokenExpirationSeconds ?? 3600,
|
||||
supportRefreshTokens: options.supportRefreshTokens ?? true,
|
||||
supportRevocation: options.supportRevocation ?? true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,9 +38,21 @@ import {
|
|||
* SDK v2's {@link WebStandardStreamableHTTPServerTransport} speaks the Fetch API
|
||||
* (`Request`/`Response`) rather than Node `req`/`res`. The JSON body is passed
|
||||
* to `handleRequest` via `parsedBody` (Express already parsed it), so the Web
|
||||
* request carries only method, URL, and headers.
|
||||
* request carries only method, URL, headers — and a `signal`.
|
||||
*
|
||||
* The signal is load-bearing, not decoration (#2140). On the 2026-07-28 era a
|
||||
* client cancels a request by closing that request's response stream, and the
|
||||
* SDK server surfaces the disconnect to a tool handler as `ctx.mcpReq.signal`
|
||||
* — reading it off this Request. Building one without a signal therefore makes
|
||||
* every fixture server deaf to cancellation, which is indistinguishable from a
|
||||
* client that never sent the signal: a test asserting that a cancel reaches the
|
||||
* server passes only if this is wired.
|
||||
*
|
||||
* `res`'s `close` is the disconnect, but only when the response had not already
|
||||
* finished — Express emits it on every completed response too, and aborting
|
||||
* then would cancel handlers that had already succeeded.
|
||||
*/
|
||||
function toWebRequest(req: Request): globalThis.Request {
|
||||
function toWebRequest(req: Request, res: Response): globalThis.Request {
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (Array.isArray(value)) {
|
||||
|
|
@ -50,7 +62,17 @@ function toWebRequest(req: Request): globalThis.Request {
|
|||
}
|
||||
}
|
||||
const url = `http://localhost${req.originalUrl || req.url}`;
|
||||
return new globalThis.Request(url, { method: req.method, headers });
|
||||
const controller = new AbortController();
|
||||
res.on("close", () => {
|
||||
if (!res.writableEnded) {
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
return new globalThis.Request(url, {
|
||||
method: req.method,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -436,7 +458,7 @@ export class TestServerHttp {
|
|||
}
|
||||
this.currentRequestHeaders = extractHeaders(req);
|
||||
try {
|
||||
const webResponse = await handler.fetch(toWebRequest(req), {
|
||||
const webResponse = await handler.fetch(toWebRequest(req, res), {
|
||||
parsedBody: req.body,
|
||||
});
|
||||
await writeWebResponse(res, webResponse);
|
||||
|
|
@ -540,9 +562,12 @@ export class TestServerHttp {
|
|||
}
|
||||
|
||||
try {
|
||||
const webResponse = await transport.handleRequest(toWebRequest(req), {
|
||||
parsedBody: req.body,
|
||||
});
|
||||
const webResponse = await transport.handleRequest(
|
||||
toWebRequest(req, res),
|
||||
{
|
||||
parsedBody: req.body,
|
||||
},
|
||||
);
|
||||
await writeWebResponse(res, webResponse);
|
||||
} catch (error) {
|
||||
// If response already sent (e.g., by OAuth middleware), don't send another
|
||||
|
|
@ -577,7 +602,7 @@ export class TestServerHttp {
|
|||
|
||||
try {
|
||||
const webResponse = await newTransport.handleRequest(
|
||||
toWebRequest(req),
|
||||
toWebRequest(req, res),
|
||||
{ parsedBody: req.body },
|
||||
);
|
||||
await writeWebResponse(res, webResponse);
|
||||
|
|
@ -615,7 +640,9 @@ export class TestServerHttp {
|
|||
// Let the transport handle the GET request
|
||||
this.currentRequestHeaders = extractHeaders(req);
|
||||
try {
|
||||
const webResponse = await transport.handleRequest(toWebRequest(req));
|
||||
const webResponse = await transport.handleRequest(
|
||||
toWebRequest(req, res),
|
||||
);
|
||||
await writeWebResponse(res, webResponse);
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
|
|
|
|||
|
|
@ -134,6 +134,9 @@ export function setupOAuthRoutes(
|
|||
if (getOAuthMode(config) === "combined") {
|
||||
setupAuthorizationEndpoint(app, config);
|
||||
setupTokenEndpoint(app, config);
|
||||
if (config.supportRevocation !== false) {
|
||||
setupRevocationEndpoint(app, config);
|
||||
}
|
||||
if (config.supportDCR) {
|
||||
setupDCREndpoint(app);
|
||||
}
|
||||
|
|
@ -256,6 +259,17 @@ function setupMetadataEndpoints(
|
|||
// RFC 9207 / SEP-2468: advertise iss on authorization responses so
|
||||
// clients must validate (and our e2e can exercise reject paths).
|
||||
authorization_response_iss_parameter_supported: true,
|
||||
...(config.supportRevocation !== false && {
|
||||
// RFC 7009 (#2144). Advertised by default so the in-repo servers
|
||||
// exercise the Inspector's revocation leg; set
|
||||
// `oauth.supportRevocation: false` to reproduce an authorization
|
||||
// server that offers none, where the Inspector must do nothing.
|
||||
revocation_endpoint: new URL("/oauth/revoke", actualIssuerUrl).href,
|
||||
revocation_endpoint_auth_methods_supported: [
|
||||
"client_secret_basic",
|
||||
"none",
|
||||
],
|
||||
}),
|
||||
...(config.supportDCR && {
|
||||
registration_endpoint: new URL("/oauth/register", actualIssuerUrl)
|
||||
.href,
|
||||
|
|
@ -634,7 +648,7 @@ function setupTokenEndpoint(
|
|||
// Generate access token
|
||||
const tokenScope =
|
||||
authCodeData.scope || config.scopesSupported?.[0] || "mcp";
|
||||
const accessToken = generateAccessToken(tokenScope);
|
||||
const accessToken = generateAccessToken(tokenScope, client_id);
|
||||
const tokenExpiration = config.tokenExpirationSeconds || 3600;
|
||||
|
||||
const response: {
|
||||
|
|
@ -657,6 +671,7 @@ function setupTokenEndpoint(
|
|||
storeRefreshToken(refreshToken, {
|
||||
clientId: client_id,
|
||||
scope: authCodeData.scope,
|
||||
accessTokens: new Set([accessToken]),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -676,7 +691,10 @@ function setupTokenEndpoint(
|
|||
|
||||
const tokenScope =
|
||||
refreshTokenData.scope || config.scopesSupported?.[0] || "mcp";
|
||||
const accessToken = generateAccessToken(tokenScope);
|
||||
const accessToken = generateAccessToken(tokenScope, client_id);
|
||||
// Keep the grant linkage current so a later revocation of this refresh
|
||||
// token also kills the access token it just minted.
|
||||
refreshTokenData.accessTokens.add(accessToken);
|
||||
const tokenExpiration = config.tokenExpirationSeconds || 3600;
|
||||
|
||||
res.json({
|
||||
|
|
@ -692,6 +710,137 @@ function setupTokenEndpoint(
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 7009 token revocation (#2144).
|
||||
*
|
||||
* Deliberately faithful on the two points the Inspector depends on, both of
|
||||
* which are easy to get wrong in a fixture:
|
||||
*
|
||||
* - **§2.2 — an unknown token is a success.** A client revoking a token the
|
||||
* server has already expired must not be told it failed, so the only 400 here
|
||||
* is a structurally invalid request (no `token` at all).
|
||||
* - **§2.1 — revoking a refresh token also invalidates its access tokens.**
|
||||
* That is why the Inspector sends one request naming the refresh token, and a
|
||||
* fixture that ignored the linkage would let a regression through silently.
|
||||
*
|
||||
* Client authentication is **enforced**, not merely accepted. RFC 7009 §2.1
|
||||
* requires it of a confidential client, and a fixture that skipped the check
|
||||
* would answer 200 to a request carrying no `Authorization` header at all — at
|
||||
* which point the end-to-end test claiming to prove the Inspector authenticates
|
||||
* correctly proves nothing. Both RFC 6749 §2.3.1 forms are accepted (Basic and
|
||||
* the request body), as is a public client identifying itself by `client_id`.
|
||||
*/
|
||||
function setupRevocationEndpoint(
|
||||
app: express.Application,
|
||||
config: OAuthConfig,
|
||||
): void {
|
||||
app.post(
|
||||
"/oauth/revoke",
|
||||
express.urlencoded({ extended: true }),
|
||||
async (req: Request, res: Response) => {
|
||||
const token: unknown = req.body?.token;
|
||||
if (typeof token !== "string" || token === "") {
|
||||
res.status(400).json({ error: "invalid_request" });
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = await authenticateRevocationClient(req, config);
|
||||
if (clientId === null) {
|
||||
res
|
||||
.status(401)
|
||||
.set("WWW-Authenticate", 'Basic realm="revoke"')
|
||||
.json({ error: "invalid_client" });
|
||||
return;
|
||||
}
|
||||
|
||||
// §2.1: only the client the token was issued to may revoke it. A token
|
||||
// belonging to someone else is left alone — and still answered 200, per
|
||||
// §2.2, since the response must not tell one client whether another's
|
||||
// token exists.
|
||||
const refreshTokenData = refreshTokens.get(token);
|
||||
if (refreshTokenData) {
|
||||
if (refreshTokenData.clientId === clientId) {
|
||||
for (const accessToken of refreshTokenData.accessTokens) {
|
||||
forgetAccessToken(accessToken);
|
||||
}
|
||||
refreshTokens.delete(token);
|
||||
}
|
||||
} else if (accessTokenClients.get(token) === clientId) {
|
||||
forgetAccessToken(token);
|
||||
}
|
||||
|
||||
// §2.2: 200 whether or not the token was known to us.
|
||||
res.status(200).end();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Drop an access token and everything recorded about it. */
|
||||
function forgetAccessToken(token: string): void {
|
||||
accessTokens.delete(token);
|
||||
accessTokenScopes.delete(token);
|
||||
accessTokenClients.delete(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate the caller of `/oauth/revoke` (RFC 7009 §2.1) and return the
|
||||
* `client_id` it authenticated as, or `null` when it did not authenticate.
|
||||
*
|
||||
* Credentials may arrive either way RFC 6749 §2.3.1 allows — an `Authorization:
|
||||
* Basic` header or `client_id`/`client_secret` in the form body — because the
|
||||
* Inspector picks between them from the metadata, and a fixture that only read
|
||||
* one would silently pass a request whose credentials went to the other place.
|
||||
*
|
||||
* A request naming no client at all is rejected: the Inspector always sends at
|
||||
* least `client_id` once it holds any client information, so an unidentified
|
||||
* request means it lost track of its credentials.
|
||||
*/
|
||||
async function authenticateRevocationClient(
|
||||
req: Request,
|
||||
config: OAuthConfig,
|
||||
): Promise<string | null> {
|
||||
let clientId: string | undefined;
|
||||
let clientSecret: string | undefined;
|
||||
|
||||
const authorization = req.get("authorization");
|
||||
if (authorization?.startsWith("Basic ")) {
|
||||
const decoded = Buffer.from(
|
||||
authorization.slice("Basic ".length),
|
||||
"base64",
|
||||
).toString("utf8");
|
||||
const separator = decoded.indexOf(":");
|
||||
if (separator === -1) return null;
|
||||
// RFC 6749 §2.3.1: each half is form-urlencoded before the colon, so the
|
||||
// server decodes each half after splitting on it. Decoding is what makes a
|
||||
// credential containing a reserved character (`:` in the id, `%` or `/` in
|
||||
// the secret) survive the round trip.
|
||||
//
|
||||
// A malformed escape makes `decodeURIComponent` throw, which Express would
|
||||
// turn into a 500 — so a bad credential would be reported as a server
|
||||
// fault rather than as the `invalid_client` 401 this endpoint means.
|
||||
try {
|
||||
clientId = decodeURIComponent(decoded.slice(0, separator));
|
||||
clientSecret = decodeURIComponent(decoded.slice(separator + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const bodyId: unknown = req.body?.client_id;
|
||||
const bodySecret: unknown = req.body?.client_secret;
|
||||
if (typeof bodyId === "string") clientId = bodyId;
|
||||
if (typeof bodySecret === "string") clientSecret = bodySecret;
|
||||
}
|
||||
|
||||
if (!clientId) return null;
|
||||
const client = await findClient(clientId, config);
|
||||
if (!client) return null;
|
||||
// A client registered with a secret must present it; a public one must not be
|
||||
// asked for one it never had.
|
||||
const ok =
|
||||
client.clientSecret === undefined || clientSecret === client.clientSecret;
|
||||
return ok ? clientId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Dynamic Client Registration endpoint
|
||||
*/
|
||||
|
|
@ -744,6 +893,15 @@ interface AuthorizationCodeData {
|
|||
interface RefreshTokenData {
|
||||
clientId: string;
|
||||
scope?: string;
|
||||
/**
|
||||
* Access tokens minted under the same grant. RFC 7009 §2.1 says an
|
||||
* authorization server asked to revoke a refresh token SHOULD also invalidate
|
||||
* the access tokens issued from it, and the Inspector relies on exactly that
|
||||
* — it sends one request naming the refresh token and expects both halves to
|
||||
* die. A fixture that dropped only the refresh token would let a client that
|
||||
* leaves live access tokens behind pass. (#2144)
|
||||
*/
|
||||
accessTokens: Set<string>;
|
||||
}
|
||||
|
||||
interface RegisteredClient {
|
||||
|
|
@ -757,6 +915,13 @@ 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>();
|
||||
/**
|
||||
* Owning `client_id` per access token. RFC 7009 §2.1 requires an authorization
|
||||
* server to verify that a token being revoked was issued to the requesting
|
||||
* client, and without this the fixture had no way to tell — so any registered
|
||||
* client could revoke another's access token. (#2144)
|
||||
*/
|
||||
const accessTokenClients = new Map<string, string>();
|
||||
const refreshTokens = new Map<string, RefreshTokenData>();
|
||||
const registeredClients = new Map<string, RegisteredClient>();
|
||||
|
||||
|
|
@ -883,10 +1048,11 @@ function getAuthorizationCode(code: string): AuthorizationCodeData | null {
|
|||
return data;
|
||||
}
|
||||
|
||||
function generateAccessToken(scope?: string): string {
|
||||
function generateAccessToken(scope?: string, clientId?: string): string {
|
||||
const token = `test_access_token_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
||||
accessTokens.add(token);
|
||||
accessTokenScopes.set(token, scope?.trim() || "mcp");
|
||||
if (clientId !== undefined) accessTokenClients.set(token, clientId);
|
||||
return token;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue