Files
next-tjwater-agent/src/product/mcp-auth-discovery.ts
T
jiang 6a49d2209e feat(agent): unify MCP tools and adopt Responses delivery
Move project and host queries into native MCP with host-owned OAuth/context,
while retaining memory, skills, actions and publication plugins. Add nearby-model
queries and explicit chat/task/learning model defaults.

Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped
session ownership, pending input edits and investigation communication.

Disable local-provider discovery: Server /health allowed native vLLM to probe
/v1/models on port 8000. A native before/after regression prevents recurrence.

Validation: 170 focused Bun tests; service/plugin typechecks; native discovery,
provider-discovery and input-inbox smoke checks; no paid model inference.
2026-10-19 18:46:58 +08:00

115 lines
6.3 KiB
TypeScript

import { discoverAuthorizationServerMetadata, discoverOAuthProtectedResourceMetadata, extractWWWAuthenticateParams } from "@modelcontextprotocol/client";
import { secureMcpUrl, validateDevHttpIssuer, type McpUrlPolicy } from "./mcp-url-policy.js";
export type McpHttpFetch = (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => ReturnType<typeof fetch>;
type Settings = McpUrlPolicy & { resource: string; issuer: string };
type Endpoint = { tokenEndpoint: string; authMethod: "client_secret_basic" | "client_secret_post" };
const unavailable = (message: string) => Object.assign(new Error(message), { status: 503 });
function oauthChallenge(response: Response) {
const header = response.headers.get("WWW-Authenticate") ?? "";
const challenges: string[] = [];
let start = 0, quoted = false, escaped = false;
for (let i = 0; i < header.length; i++) {
const char = header[i];
if (escaped) { escaped = false; continue; }
if (quoted && char === "\\") { escaped = true; continue; }
if (char === '"') quoted = !quoted;
if (!quoted && char === "," && /^\s*[^\s=,]+\s+(?!=)/.test(header.slice(i + 1))) {
challenges.push(header.slice(start, i).trim()); start = i + 1;
}
}
challenges.push(header.slice(start).trim());
for (const value of challenges) {
if (!/^(Bearer|DPoP)\s/i.test(value)) continue;
const parsed = extractWWWAuthenticateParams(new Response(null, { headers: { "WWW-Authenticate": value } }));
if (parsed.resourceMetadataUrl) return parsed;
}
return {};
}
/** Public metadata only. Configured issuer owns the pre-registered credentials. */
export class McpAuthDiscovery {
private cached?: { endpoint: Endpoint; expires: number };
private pending?: Promise<Endpoint>;
private epoch = 0;
constructor(private readonly settings: Settings, private readonly http: McpHttpFetch = fetch) {}
invalidate() { this.epoch++; this.cached = undefined; }
resolve(): Promise<Endpoint> {
if (this.cached && this.cached.expires > Date.now()) return Promise.resolve(this.cached.endpoint);
if (!this.pending) {
const epoch = this.epoch;
this.pending = this.discover().then((endpoint) => {
if (epoch === this.epoch) this.cached = { endpoint, expires: Date.now() + 300_000 };
return endpoint;
}).finally(() => { this.pending = undefined; });
}
return this.pending;
}
private async discover(): Promise<Endpoint> {
validateDevHttpIssuer(this.settings);
if (this.settings.devHttpIssuer && this.settings.devHttpIssuer !== this.settings.issuer) throw unavailable("MCP development issuer must match the configured issuer");
secureMcpUrl(this.settings.resource, this.settings);
secureMcpUrl(this.settings.issuer, this.settings, true);
const deadline = AbortSignal.timeout(10_000);
const publicFetch: McpHttpFetch = async (input, init) => {
const url = secureMcpUrl(input instanceof Request ? input.url : String(input), this.settings, true);
const headers = new Headers(init?.headers);
headers.delete("Authorization"); headers.delete("Cookie");
const response = await this.http(url, { ...init, headers, credentials: "omit", redirect: "error", signal: deadline });
// SDK parsers receive bounded public documents; no token or client secret is attached.
const reader = response.body?.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
if (reader) {
try {
for (;;) {
deadline.throwIfAborted();
const part = await reader.read();
if (part.done) break;
size += part.value.length;
if (size > 64 * 1024) throw unavailable("MCP OAuth metadata exceeds its size budget");
chunks.push(part.value);
}
} finally { await reader.cancel().catch(() => {}); }
}
return new Response(size ? Buffer.concat(chunks) : null, { status: response.status, headers: response.headers });
};
try {
const probe = await publicFetch(this.settings.resource, {
method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2026-07-28", "Mcp-Method": "server/discover" },
body: JSON.stringify({ jsonrpc: "2.0", id: "auth-discovery", method: "server/discover", params: { _meta: {
"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { name: "tjwater-agent-host", version: "1.0.0" }, "io.modelcontextprotocol/clientCapabilities": {},
} } }),
});
const challenge = probe.status === 401 ? oauthChallenge(probe) : {};
await probe.body?.cancel();
const resource = await discoverOAuthProtectedResourceMetadata(this.settings.resource, {
protocolVersion: "2026-07-28", resourceMetadataUrl: challenge.resourceMetadataUrl,
}, publicFetch);
if (new URL(resource.resource).href !== new URL(this.settings.resource).href || !resource.authorization_servers?.includes(this.settings.issuer)) {
throw unavailable("MCP resource metadata differs from the configured resource or issuer");
}
const metadata = await discoverAuthorizationServerMetadata(this.settings.issuer, { fetchFn: publicFetch, protocolVersion: "2026-07-28" });
// Enforce exact issuer equality even where an SDK permits trailing-slash normalization.
if (!metadata || metadata.issuer !== this.settings.issuer || !metadata.token_endpoint) {
throw unavailable("MCP OAuth issuer metadata is incompatible with its registration");
}
const tokenEndpoint = secureMcpUrl(metadata.token_endpoint, this.settings, true);
if (new URL(this.settings.issuer).protocol === "https:" && tokenEndpoint.protocol !== "https:") {
throw unavailable("MCP OAuth token endpoint cannot downgrade HTTPS");
}
const methods = metadata.token_endpoint_auth_methods_supported ?? ["client_secret_basic"];
const authMethod = methods.includes("client_secret_post") ? "client_secret_post" : methods.includes("client_secret_basic") ? "client_secret_basic" : undefined;
if (!authMethod) throw unavailable("MCP OAuth server does not support the configured client secret");
return { tokenEndpoint: tokenEndpoint.href, authMethod };
} catch {
throw unavailable("MCP OAuth discovery failed or is incompatible with the configured resource and issuer");
}
}
}