2491 lines
96 KiB
TypeScript
2491 lines
96 KiB
TypeScript
import assert from "node:assert/strict"
|
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
|
|
import { describe, it } from "node:test"
|
|
import { z } from "zod"
|
|
import {
|
|
createEnterpriseMcpClient,
|
|
EnterpriseMcpCatalogError,
|
|
EnterpriseMcpClientError,
|
|
EnterpriseMcpToolResultError,
|
|
EnterpriseMcpToolInputError,
|
|
EnterpriseMcpOAuthContractError,
|
|
validateMcpAuthorizationResponseIssuer,
|
|
type EnterpriseMcpDiagnosticEvent,
|
|
type EnterpriseMcpConnection,
|
|
type EnterpriseMcpFetch,
|
|
type EnterpriseMcpOAuthAuthorizationHandle,
|
|
type EnterpriseMcpOAuthClientRegistration,
|
|
type EnterpriseMcpOAuthCredential,
|
|
type EnterpriseMcpOAuthPersistence,
|
|
type StoredOAuthClientInformation,
|
|
type StoredOAuthTokens,
|
|
} from "../src/index.js"
|
|
import { EnterpriseMcpOAuthProvider } from "../src/oauth-provider.js"
|
|
import { createEnterpriseMcpRequestObserver } from "../src/request-observer.js"
|
|
import { collectEnterpriseMcpTools } from "../src/tool-catalog.js"
|
|
import {
|
|
assertEnterpriseMcpResourceResult,
|
|
collectEnterpriseMcpResources,
|
|
} from "../src/resource-catalog.js"
|
|
import { selectClientAuthMethod } from "@modelcontextprotocol/client"
|
|
import type { OAuthDiscoveryState } from "@modelcontextprotocol/client"
|
|
|
|
const rpcRequestSchema = z.object({
|
|
id: z.union([z.string(), z.number()]).optional(),
|
|
method: z.string(),
|
|
}).passthrough()
|
|
const rpcProgressRequestSchema = rpcRequestSchema.extend({
|
|
params: z.object({
|
|
_meta: z.object({
|
|
progressToken: z.union([z.string(), z.number()]).optional(),
|
|
}).passthrough().optional(),
|
|
}).passthrough().optional(),
|
|
})
|
|
const textToolResultSchema = z.object({
|
|
content: z.array(z.object({ type: z.literal("text"), text: z.string() })),
|
|
}).passthrough()
|
|
|
|
type MockMcpOptions = {
|
|
toolError?: boolean
|
|
toolErrorText?: string
|
|
expectedApiKey?: string
|
|
}
|
|
|
|
function requestText(body: BodyInit | null | undefined): string {
|
|
return typeof body === "string" ? body : ""
|
|
}
|
|
|
|
function mockMcpFetch(options: MockMcpOptions = {}): EnterpriseMcpFetch {
|
|
return async (_url, init) => {
|
|
if (options.expectedApiKey) {
|
|
const headers = new Headers(init?.headers)
|
|
assert.equal(headers.get("authorization"), `Bearer ${options.expectedApiKey}`)
|
|
}
|
|
|
|
const body = requestText(init?.body)
|
|
if (!body) return new Response(null, { status: 202 })
|
|
const parsed: unknown = JSON.parse(body)
|
|
const request = rpcRequestSchema.parse(parsed)
|
|
if (request.method === "notifications/initialized") {
|
|
return new Response(null, { status: 202 })
|
|
}
|
|
|
|
if (request.method === "initialize") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
result: {
|
|
protocolVersion: "2025-06-18",
|
|
capabilities: {
|
|
tools: {},
|
|
resources: {},
|
|
extensions: { "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] } },
|
|
},
|
|
serverInfo: { name: "enterprise-mcp-test", version: "1.0.0" },
|
|
},
|
|
})
|
|
}
|
|
|
|
if (request.method === "tools/list") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
result: {
|
|
tools: [{
|
|
name: "lookup-record",
|
|
description: "Looks up an enterprise record",
|
|
inputSchema: { type: "object", properties: {} },
|
|
}],
|
|
},
|
|
})
|
|
}
|
|
|
|
if (request.method === "tools/call") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
result: {
|
|
content: [{ type: "text", text: options.toolError ? (options.toolErrorText ?? "Provider rejected the operation") : "Record found" }],
|
|
structuredContent: { record: { id: "incident-42" } },
|
|
_meta: { providerTrace: "trace-42" },
|
|
isError: options.toolError ?? false,
|
|
},
|
|
})
|
|
}
|
|
|
|
if (request.method === "resources/list") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
result: {
|
|
resources: [{
|
|
name: "Incident App",
|
|
uri: "ui://incident/view.html",
|
|
mimeType: "text/html;profile=mcp-app",
|
|
_meta: { ui: { csp: { connectDomains: [] } } },
|
|
}],
|
|
},
|
|
})
|
|
}
|
|
|
|
if (request.method === "resources/templates/list") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
result: { resourceTemplates: [{ name: "Incident", uriTemplate: "incident://{id}" }] },
|
|
})
|
|
}
|
|
|
|
if (request.method === "resources/read") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
result: {
|
|
contents: [{
|
|
uri: "ui://incident/view.html",
|
|
mimeType: "text/html;profile=mcp-app",
|
|
text: "<!doctype html><html><body>Incident</body></html>",
|
|
_meta: { ui: { prefersBorder: true } },
|
|
}],
|
|
},
|
|
})
|
|
}
|
|
|
|
return new Response(null, { status: 404 })
|
|
}
|
|
}
|
|
|
|
async function requestBody(request: IncomingMessage): Promise<string> {
|
|
let body = ""
|
|
for await (const chunk of request) {
|
|
body += typeof chunk === "string" ? chunk : chunk.toString("utf8")
|
|
}
|
|
return body
|
|
}
|
|
|
|
function sendJson(response: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}): void {
|
|
response.writeHead(status, { "content-type": "application/json", ...headers })
|
|
response.end(JSON.stringify(body))
|
|
}
|
|
|
|
async function sendMcpResponse(request: IncomingMessage, response: ServerResponse, options: { rejectToolsList?: boolean } = {}): Promise<void> {
|
|
const parsed: unknown = JSON.parse(await requestBody(request))
|
|
const rpc = rpcRequestSchema.parse(parsed)
|
|
if (rpc.method === "notifications/initialized") {
|
|
response.writeHead(202)
|
|
response.end()
|
|
return
|
|
}
|
|
if (rpc.method === "initialize") {
|
|
sendJson(response, 200, {
|
|
jsonrpc: "2.0",
|
|
id: rpc.id,
|
|
result: {
|
|
protocolVersion: "2025-06-18",
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "oauth-enterprise-mcp-test", version: "1.0.0" },
|
|
},
|
|
})
|
|
return
|
|
}
|
|
if (rpc.method === "tools/list") {
|
|
if (options.rejectToolsList) {
|
|
sendJson(response, 403, { error: "provider_tool_policy_denied" })
|
|
return
|
|
}
|
|
sendJson(response, 200, {
|
|
jsonrpc: "2.0",
|
|
id: rpc.id,
|
|
result: {
|
|
tools: [{ name: "oauth-tool", inputSchema: { type: "object", properties: {} } }],
|
|
},
|
|
})
|
|
return
|
|
}
|
|
sendJson(response, 404, { error: "not_found" })
|
|
}
|
|
|
|
async function startOAuthMcpServer(options: {
|
|
rejectAuthenticatedMcp?: boolean
|
|
rejectAuthenticatedToolsList?: boolean
|
|
clientMetadataSupported?: boolean
|
|
scopeLessChallenge?: boolean
|
|
advertisedScopes?: string[]
|
|
resourceAdvertisedScopes?: string[]
|
|
authorizationAdvertisedScopes?: string[]
|
|
} = {}) {
|
|
let origin = ""
|
|
let capturedRegistration: Record<string, unknown> | null = null
|
|
const server = createServer(async (request, response) => {
|
|
try {
|
|
const url = new URL(request.url ?? "/", origin)
|
|
if (url.pathname === "/.well-known/oauth-protected-resource/mcp") {
|
|
sendJson(response, 200, {
|
|
resource: `${origin}/mcp`,
|
|
authorization_servers: [origin],
|
|
scopes_supported: options.resourceAdvertisedScopes ?? options.advertisedScopes ?? ["tools.read"],
|
|
bearer_methods_supported: ["header"],
|
|
})
|
|
return
|
|
}
|
|
if (url.pathname === "/.well-known/oauth-authorization-server") {
|
|
sendJson(response, 200, {
|
|
issuer: origin,
|
|
authorization_endpoint: `${origin}/authorize`,
|
|
token_endpoint: `${origin}/token`,
|
|
registration_endpoint: `${origin}/register`,
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
scopes_supported: options.authorizationAdvertisedScopes ?? options.advertisedScopes ?? ["tools.read"],
|
|
...(options.clientMetadataSupported ? { client_id_metadata_document_supported: true } : {}),
|
|
})
|
|
return
|
|
}
|
|
if (url.pathname === "/register") {
|
|
const registration: unknown = JSON.parse(await requestBody(request))
|
|
const metadata = z.object({ redirect_uris: z.array(z.string()) }).passthrough().parse(registration)
|
|
capturedRegistration = metadata
|
|
sendJson(response, 201, {
|
|
client_id: "enterprise-test-client",
|
|
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
token_endpoint_auth_method: "none",
|
|
redirect_uris: metadata.redirect_uris,
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
scope: "tools.read",
|
|
})
|
|
return
|
|
}
|
|
if (url.pathname === "/token") {
|
|
const form = new URLSearchParams(await requestBody(request))
|
|
const grantType = form.get("grant_type")
|
|
if (grantType === "authorization_code") {
|
|
assert.equal(form.get("code"), "approved-code")
|
|
assert.ok(form.get("code_verifier"))
|
|
} else {
|
|
assert.equal(grantType, "refresh_token")
|
|
assert.equal(form.get("refresh_token"), "enterprise-refresh-token")
|
|
}
|
|
sendJson(response, 200, {
|
|
access_token: "enterprise-access-token",
|
|
refresh_token: "enterprise-refresh-token",
|
|
token_type: "Bearer",
|
|
expires_in: 3600,
|
|
scope: "tools.read",
|
|
}, { "cache-control": "no-store" })
|
|
return
|
|
}
|
|
if (url.pathname === "/mcp") {
|
|
if (request.headers.authorization !== "Bearer enterprise-access-token") {
|
|
response.writeHead(401, {
|
|
"www-authenticate": options.scopeLessChallenge
|
|
? `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`
|
|
: `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp", scope="tools.read"`,
|
|
})
|
|
response.end()
|
|
return
|
|
}
|
|
if (options.rejectAuthenticatedMcp) {
|
|
sendJson(response, 403, { error: "provider_policy_denied" })
|
|
return
|
|
}
|
|
await sendMcpResponse(request, response, { rejectToolsList: options.rejectAuthenticatedToolsList })
|
|
return
|
|
}
|
|
sendJson(response, 404, { error: "not_found" })
|
|
} catch {
|
|
sendJson(response, 500, { error: "oauth_test_server_error" })
|
|
}
|
|
})
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("error", reject)
|
|
server.listen(0, "127.0.0.1", resolve)
|
|
})
|
|
const address = server.address()
|
|
if (!address || typeof address === "string") {
|
|
server.close()
|
|
throw new Error("OAuth MCP test server did not bind to a TCP port.")
|
|
}
|
|
origin = `http://127.0.0.1:${address.port}`
|
|
return {
|
|
origin,
|
|
registration: () => capturedRegistration,
|
|
close: () => new Promise<void>((resolve, reject) => {
|
|
server.close((error) => error ? reject(error) : resolve())
|
|
}),
|
|
}
|
|
}
|
|
|
|
async function startProgressMcpServer(options: {
|
|
resultDelayMs: number
|
|
progressIntervalMs: number
|
|
}) {
|
|
let origin = ""
|
|
const server = createServer(async (request, response) => {
|
|
try {
|
|
const url = new URL(request.url ?? "/", origin)
|
|
if (url.pathname !== "/mcp") {
|
|
sendJson(response, 404, { error: "not_found" })
|
|
return
|
|
}
|
|
const body = await requestBody(request)
|
|
if (!body) {
|
|
response.writeHead(202)
|
|
response.end()
|
|
return
|
|
}
|
|
const parsed: unknown = JSON.parse(body)
|
|
const rpc = rpcProgressRequestSchema.parse(parsed)
|
|
if (rpc.method === "notifications/initialized") {
|
|
response.writeHead(202)
|
|
response.end()
|
|
return
|
|
}
|
|
if (rpc.method === "initialize") {
|
|
sendJson(response, 200, {
|
|
jsonrpc: "2.0",
|
|
id: rpc.id,
|
|
result: {
|
|
protocolVersion: "2025-06-18",
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "progress-enterprise-mcp-test", version: "1.0.0" },
|
|
},
|
|
})
|
|
return
|
|
}
|
|
if (rpc.method !== "tools/call") {
|
|
sendJson(response, 404, { error: "not_found" })
|
|
return
|
|
}
|
|
|
|
response.writeHead(200, {
|
|
"content-type": "text/event-stream",
|
|
"cache-control": "no-cache",
|
|
})
|
|
const progressToken = rpc.params?._meta?.progressToken
|
|
let progress = 0
|
|
const writeMessage = (message: unknown) => {
|
|
response.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`)
|
|
}
|
|
const progressTimer = setInterval(() => {
|
|
if (progressToken === undefined) return
|
|
progress += 1
|
|
writeMessage({
|
|
jsonrpc: "2.0",
|
|
method: "notifications/progress",
|
|
params: { progressToken, progress },
|
|
})
|
|
}, options.progressIntervalMs)
|
|
const resultTimer = setTimeout(() => {
|
|
clearInterval(progressTimer)
|
|
writeMessage({
|
|
jsonrpc: "2.0",
|
|
id: rpc.id,
|
|
result: { content: [{ type: "text", text: "streamed result" }] },
|
|
})
|
|
response.end()
|
|
}, options.resultDelayMs)
|
|
response.on("close", () => {
|
|
clearInterval(progressTimer)
|
|
clearTimeout(resultTimer)
|
|
})
|
|
} catch {
|
|
sendJson(response, 500, { error: "progress_test_server_error" })
|
|
}
|
|
})
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("error", reject)
|
|
server.listen(0, "127.0.0.1", resolve)
|
|
})
|
|
const address = server.address()
|
|
if (!address || typeof address === "string") {
|
|
server.close()
|
|
throw new Error("Progress MCP test server did not bind to a TCP port.")
|
|
}
|
|
origin = `http://127.0.0.1:${address.port}`
|
|
return {
|
|
origin,
|
|
close: () => new Promise<void>((resolve, reject) => {
|
|
server.close((error) => error ? reject(error) : resolve())
|
|
}),
|
|
}
|
|
}
|
|
|
|
function noAuthConnection(): EnterpriseMcpConnection {
|
|
return {
|
|
id: "connection-1",
|
|
serverUrl: "https://mcp.example.test/mcp",
|
|
authorization: { type: "none" },
|
|
}
|
|
}
|
|
|
|
describe("enterprise MCP client", () => {
|
|
it("preserves the last failed OAuth phase when a later cleanup request succeeds", async () => {
|
|
const controller = new AbortController()
|
|
const observer = createEnterpriseMcpRequestObserver({
|
|
connectionId: "connection-1",
|
|
operationPhase: "authorization-callback",
|
|
fetch: async (url) => new URL(url).pathname.endsWith("/token")
|
|
? Response.json({ error: "invalid_client" }, { status: 401 })
|
|
: Response.json({ issuer: "https://provider.example.test" }),
|
|
signal: controller.signal,
|
|
clock: { now: () => Date.now() },
|
|
})
|
|
|
|
await observer.fetch("https://provider.example.test/token", {
|
|
method: "POST",
|
|
body: new URLSearchParams({ grant_type: "authorization_code", code: "code" }),
|
|
})
|
|
await observer.fetch("https://provider.example.test/.well-known/oauth-authorization-server")
|
|
|
|
assert.equal(observer.lastRequestPhase(), "oauth-server-discovery")
|
|
assert.equal(observer.lastFailedRequestPhase(), "oauth-token-exchange")
|
|
})
|
|
|
|
it("preserves tools, resources, UI metadata, and tool results over MCP Streamable HTTP", async () => {
|
|
const events: EnterpriseMcpDiagnosticEvent[] = []
|
|
const client = createEnterpriseMcpClient({
|
|
fetch: mockMcpFetch(),
|
|
diagnosticSink: (event) => events.push(event),
|
|
})
|
|
const connection = noAuthConnection()
|
|
const redirectUri = "https://den.example.test/v1/mcp-connections/connection-1/connect/callback"
|
|
|
|
assert.deepEqual(await client.connect({ connection, redirectUri }), { status: "connected" })
|
|
const tools = await client.listTools({ connection, redirectUri })
|
|
assert.equal(tools[0]?.name, "lookup-record")
|
|
const result = await client.callTool({
|
|
connection,
|
|
redirectUri,
|
|
toolName: "lookup-record",
|
|
arguments: { table: "incident" },
|
|
})
|
|
assert.equal("isError" in result ? result.isError : undefined, false)
|
|
assert.deepEqual(result.structuredContent, { record: { id: "incident-42" } })
|
|
assert.deepEqual(result._meta, { providerTrace: "trace-42" })
|
|
const resources = await client.listResources({ connection, redirectUri })
|
|
assert.deepEqual(resources[0]?._meta, { ui: { csp: { connectDomains: [] } } })
|
|
const templates = await client.listResourceTemplates({ connection, redirectUri })
|
|
assert.equal(templates[0]?.uriTemplate, "incident://{id}")
|
|
const resource = await client.readResource({ connection, redirectUri, uri: "ui://incident/view.html" })
|
|
assert.deepEqual(resource.contents[0]?._meta, { ui: { prefersBorder: true } })
|
|
const descriptor = await client.describeServer({ connection, redirectUri })
|
|
assert.deepEqual(descriptor.serverInfo, { name: "enterprise-mcp-test", version: "1.0.0" })
|
|
assert.deepEqual(descriptor.capabilities.extensions, {
|
|
"io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] },
|
|
})
|
|
assert.ok(events.some((event) => event.kind !== "credential-invalidation" && event.requestPhase === "mcp-initialize" && event.outcome === "succeeded"))
|
|
assert.ok(events.some((event) => event.kind !== "credential-invalidation" && event.requestPhase === "mcp-tool-discovery" && event.outcome === "succeeded"))
|
|
assert.ok(events.some((event) => event.kind !== "credential-invalidation" && event.requestPhase === "mcp-tool-execution" && event.outcome === "succeeded"))
|
|
assert.ok(events.some((event) => event.kind !== "credential-invalidation" && event.requestPhase === "mcp-resource-discovery" && event.outcome === "succeeded"))
|
|
assert.ok(events.some((event) => event.kind !== "credential-invalidation" && event.requestPhase === "mcp-resource-read" && event.outcome === "succeeded"))
|
|
})
|
|
|
|
it("connects to a spec-legal MCP server that does not advertise tools", async () => {
|
|
let toolsListCalls = 0
|
|
const client = createEnterpriseMcpClient({
|
|
fetch: async (_url, init) => {
|
|
const body = requestText(init?.body)
|
|
if (!body) return new Response(null, { status: 202 })
|
|
const request = rpcRequestSchema.parse(JSON.parse(body))
|
|
if (request.method === "notifications/initialized") return new Response(null, { status: 202 })
|
|
if (request.method === "tools/list") {
|
|
toolsListCalls += 1
|
|
return Response.json({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Method not found" } })
|
|
}
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
result: {
|
|
protocolVersion: "2025-06-18",
|
|
capabilities: { resources: {} },
|
|
serverInfo: { name: "resources-only", version: "1.0.0" },
|
|
},
|
|
})
|
|
},
|
|
})
|
|
|
|
assert.deepEqual(await client.connect({
|
|
connection: noAuthConnection(),
|
|
redirectUri: "https://den.example.test/callback",
|
|
}), { status: "connected" })
|
|
assert.equal(toolsListCalls, 0)
|
|
})
|
|
|
|
it("sends Den's API key as a bearer credential", async () => {
|
|
const client = createEnterpriseMcpClient({ fetch: mockMcpFetch({ expectedApiKey: "secret-test-key" }) })
|
|
const result = await client.connect({
|
|
connection: {
|
|
...noAuthConnection(),
|
|
authorization: { type: "api-key", token: "secret-test-key" },
|
|
},
|
|
redirectUri: "https://den.example.test/callback",
|
|
})
|
|
assert.deepEqual(result, { status: "connected" })
|
|
})
|
|
|
|
it("does not let a diagnostic consumer change a connection outcome", async () => {
|
|
const client = createEnterpriseMcpClient({
|
|
fetch: mockMcpFetch(),
|
|
diagnosticSink: () => {
|
|
throw new Error("diagnostic sink failed")
|
|
},
|
|
})
|
|
assert.deepEqual(await client.connect({
|
|
connection: noAuthConnection(),
|
|
redirectUri: "https://den.example.test/callback",
|
|
}), { status: "connected" })
|
|
})
|
|
|
|
it("identifies the exact request phase when endpoint access fails", async () => {
|
|
const client = createEnterpriseMcpClient({
|
|
fetch: async () => {
|
|
throw new Error("simulated network failure")
|
|
},
|
|
})
|
|
|
|
await assert.rejects(
|
|
client.connect({
|
|
connection: noAuthConnection(),
|
|
redirectUri: "https://den.example.test/callback",
|
|
}),
|
|
(error: unknown) => {
|
|
assert.ok(error instanceof EnterpriseMcpClientError)
|
|
assert.equal(error.code, "MCP_CONNECTION_HANDSHAKE_FAILED")
|
|
assert.equal(error.operationPhase, "connection-handshake")
|
|
assert.equal(error.requestPhase, "mcp-discovery")
|
|
assert.match(error.message, /MCP connection handshake/)
|
|
return true
|
|
},
|
|
)
|
|
})
|
|
|
|
it("honors an injected absolute lifecycle deadline", async () => {
|
|
const controller = new AbortController()
|
|
const expiresAt = Date.now() + 40
|
|
const timer = setTimeout(() => controller.abort(new Error("shared deadline reached")), 40)
|
|
const client = createEnterpriseMcpClient({
|
|
lifecycle: { expiresAt, signal: controller.signal },
|
|
fetch: async (_url, init) => new Promise<Response>((_resolve, reject) => {
|
|
if (init?.signal?.aborted) {
|
|
reject(init.signal.reason)
|
|
return
|
|
}
|
|
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true })
|
|
}),
|
|
})
|
|
const startedAt = Date.now()
|
|
try {
|
|
await assert.rejects(client.connect({
|
|
connection: noAuthConnection(),
|
|
redirectUri: "https://den.example.test/callback",
|
|
}))
|
|
assert.ok(Date.now() - startedAt < 500)
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
})
|
|
|
|
it("honors an injected lifecycle longer than the per-request timeout while progress streams", async () => {
|
|
const server = await startProgressMcpServer({ resultDelayMs: 500, progressIntervalMs: 40 })
|
|
try {
|
|
const client = createEnterpriseMcpClient({
|
|
fetch,
|
|
operationTimeoutMs: 150,
|
|
lifecycle: { expiresAt: Date.now() + 1_200, signal: new AbortController().signal },
|
|
})
|
|
const result = await client.callTool({
|
|
connection: {
|
|
id: "progress-connection",
|
|
serverUrl: `${server.origin}/mcp`,
|
|
authorization: { type: "none" },
|
|
},
|
|
redirectUri: "https://den.example.test/callback",
|
|
toolName: "lookup-record",
|
|
arguments: {},
|
|
})
|
|
const parsedResult = textToolResultSchema.parse(result)
|
|
assert.equal(parsedResult.content[0]?.text, "streamed result")
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
it("still stops a progressing provider at the injected absolute lifecycle", async () => {
|
|
const server = await startProgressMcpServer({ resultDelayMs: 1_500, progressIntervalMs: 40 })
|
|
try {
|
|
const client = createEnterpriseMcpClient({
|
|
fetch,
|
|
operationTimeoutMs: 150,
|
|
lifecycle: { expiresAt: Date.now() + 700, signal: new AbortController().signal },
|
|
})
|
|
const startedAt = Date.now()
|
|
await assert.rejects(
|
|
client.callTool({
|
|
connection: {
|
|
id: "progress-deadline-connection",
|
|
serverUrl: `${server.origin}/mcp`,
|
|
authorization: { type: "none" },
|
|
},
|
|
redirectUri: "https://den.example.test/callback",
|
|
toolName: "lookup-record",
|
|
arguments: {},
|
|
}),
|
|
(error: unknown) => error instanceof EnterpriseMcpClientError
|
|
&& error.operationPhase === "tool-execution",
|
|
)
|
|
const elapsedMs = Date.now() - startedAt
|
|
assert.ok(elapsedMs >= 500, `expected lifecycle timeout to outlast per-request timeout, got ${elapsedMs}ms`)
|
|
assert.ok(elapsedMs < 2_000, `expected lifecycle timeout before the provider result, got ${elapsedMs}ms`)
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
it("treats an MCP isError tool result as a failed operation", async () => {
|
|
const client = createEnterpriseMcpClient({ fetch: mockMcpFetch({ toolError: true }) })
|
|
await assert.rejects(
|
|
client.callTool({
|
|
connection: noAuthConnection(),
|
|
redirectUri: "https://den.example.test/callback",
|
|
toolName: "lookup-record",
|
|
arguments: {},
|
|
}),
|
|
(error: unknown) => {
|
|
assert.ok(error instanceof EnterpriseMcpClientError)
|
|
assert.equal(error.code, "MCP_TOOL_EXECUTION_FAILED")
|
|
assert.ok(error.cause instanceof EnterpriseMcpToolResultError)
|
|
return true
|
|
},
|
|
)
|
|
})
|
|
|
|
it("preserves MCP isError results for transparent proxy callers", async () => {
|
|
const client = createEnterpriseMcpClient({ fetch: mockMcpFetch({ toolError: true }) })
|
|
const result = await client.callToolRaw({
|
|
connection: noAuthConnection(),
|
|
redirectUri: "https://den.example.test/callback",
|
|
toolName: "lookup-record",
|
|
arguments: {},
|
|
})
|
|
assert.equal(result.isError, true)
|
|
assert.match(JSON.stringify(result.content), /Provider rejected the operation/)
|
|
})
|
|
|
|
it("rejects resource URIs over the byte limit before opening a provider connection", async () => {
|
|
let fetchCount = 0
|
|
const client = createEnterpriseMcpClient({
|
|
fetch: async () => {
|
|
fetchCount += 1
|
|
return new Response(null, { status: 500 })
|
|
},
|
|
})
|
|
await assert.rejects(
|
|
client.readResource({
|
|
connection: noAuthConnection(),
|
|
redirectUri: "https://den.example.test/callback",
|
|
uri: `ui://${"🚀".repeat(4_096)}`,
|
|
}),
|
|
(error: unknown) => error instanceof EnterpriseMcpClientError
|
|
&& error.operationPhase === "configuration",
|
|
)
|
|
assert.equal(fetchCount, 0)
|
|
})
|
|
|
|
it("retains only a safe invalid-argument signal from standardized MCP SDK tool errors", async () => {
|
|
const privateText = "Input validation error: Invalid arguments for tool lookup-record: private provider detail"
|
|
const client = createEnterpriseMcpClient({
|
|
fetch: mockMcpFetch({ toolError: true, toolErrorText: privateText }),
|
|
})
|
|
await assert.rejects(
|
|
client.callTool({
|
|
connection: noAuthConnection(),
|
|
redirectUri: "https://den.example.test/callback",
|
|
toolName: "lookup-record",
|
|
arguments: {},
|
|
}),
|
|
(error: unknown) => {
|
|
assert.ok(error instanceof EnterpriseMcpClientError)
|
|
assert.ok(error.cause instanceof EnterpriseMcpToolResultError)
|
|
assert.deepEqual(error.cause.providerSignal, { category: "invalid_arguments" })
|
|
assert.doesNotMatch(JSON.stringify(error.cause), /private provider detail/)
|
|
return true
|
|
},
|
|
)
|
|
})
|
|
|
|
it("rejects oversized or cyclic tool arguments before opening a provider connection", async () => {
|
|
let fetchCount = 0
|
|
const client = createEnterpriseMcpClient({
|
|
fetch: async () => {
|
|
fetchCount += 1
|
|
return new Response(null, { status: 500 })
|
|
},
|
|
})
|
|
await assert.rejects(
|
|
client.callTool({
|
|
connection: noAuthConnection(),
|
|
redirectUri: "https://den.example.test/callback",
|
|
toolName: "lookup-record",
|
|
arguments: { payload: "x".repeat(1024 * 1024) },
|
|
}),
|
|
(error: unknown) => error instanceof EnterpriseMcpClientError
|
|
&& error.cause instanceof EnterpriseMcpToolInputError
|
|
&& error.cause.code === "MCP_TOOL_ARGUMENT_SIZE_LIMIT",
|
|
)
|
|
const cyclic: Record<string, unknown> = {}
|
|
cyclic.self = cyclic
|
|
await assert.rejects(
|
|
client.callTool({
|
|
connection: noAuthConnection(),
|
|
redirectUri: "https://den.example.test/callback",
|
|
toolName: "lookup-record",
|
|
arguments: cyclic,
|
|
}),
|
|
(error: unknown) => error instanceof EnterpriseMcpClientError
|
|
&& error.cause instanceof EnterpriseMcpToolInputError
|
|
&& error.cause.code === "MCP_TOOL_ARGUMENT_CYCLE",
|
|
)
|
|
assert.equal(fetchCount, 0)
|
|
})
|
|
})
|
|
|
|
class MemoryOAuthPersistence implements EnterpriseMcpOAuthPersistence {
|
|
registration: EnterpriseMcpOAuthClientRegistration | undefined
|
|
credential: EnterpriseMcpOAuthCredential | undefined
|
|
authorizationRecords = new Map<string, { handle: EnterpriseMcpOAuthAuthorizationHandle; codeVerifier: string }>()
|
|
invalidationCount = 0
|
|
revision = 0
|
|
discoveryState: OAuthDiscoveryState | undefined
|
|
|
|
private nextRevision(): string {
|
|
this.revision += 1
|
|
return `revision-${this.revision}`
|
|
}
|
|
|
|
private assertActive(input: { commitExpiresAt: number; signal: AbortSignal }): void {
|
|
if (input.signal.aborted || input.commitExpiresAt <= Date.now()) throw new Error("persistence deadline expired")
|
|
}
|
|
|
|
readonly clientRegistrations = {
|
|
load: async () => this.registration,
|
|
save: async (input: {
|
|
context: { commitExpiresAt: number; signal: AbortSignal }
|
|
clientInformation: StoredOAuthClientInformation
|
|
expiresAt?: number
|
|
source: "client-metadata" | "dynamic"
|
|
}) => {
|
|
this.assertActive(input.context)
|
|
if (!this.registration) {
|
|
this.registration = {
|
|
clientInformation: input.clientInformation,
|
|
revision: this.nextRevision(),
|
|
expiresAt: input.expiresAt,
|
|
source: input.source,
|
|
}
|
|
}
|
|
return this.registration
|
|
},
|
|
invalidate: async () => {
|
|
this.registration = undefined
|
|
},
|
|
}
|
|
|
|
readonly authorizations = {
|
|
begin: async (input: {
|
|
context: { commitExpiresAt: number; signal: AbortSignal }
|
|
id: string
|
|
codeVerifier: string
|
|
expiresAt: number
|
|
clientRegistrationRevision?: string
|
|
}) => {
|
|
this.assertActive(input.context)
|
|
this.authorizationRecords.set(input.id, {
|
|
handle: {
|
|
id: input.id,
|
|
revision: this.nextRevision(),
|
|
expiresAt: input.expiresAt,
|
|
clientRegistrationRevision: input.clientRegistrationRevision,
|
|
},
|
|
codeVerifier: input.codeVerifier,
|
|
})
|
|
},
|
|
load: async (input: { id: string }) => this.authorizationRecords.get(input.id),
|
|
invalidate: async (input: { id: string }) => {
|
|
this.authorizationRecords.delete(input.id)
|
|
},
|
|
}
|
|
|
|
readonly discovery = {
|
|
load: async () => this.discoveryState,
|
|
save: async (input: { state: OAuthDiscoveryState }) => {
|
|
this.discoveryState = input.state
|
|
},
|
|
invalidate: async () => {
|
|
this.discoveryState = undefined
|
|
},
|
|
}
|
|
|
|
readonly credentials = {
|
|
load: async () => this.credential,
|
|
save: async (input: {
|
|
context: { commitExpiresAt: number; signal: AbortSignal }
|
|
tokens: StoredOAuthTokens
|
|
expiresAt?: number
|
|
source: "authorization-code" | "refresh"
|
|
authorization?: EnterpriseMcpOAuthAuthorizationHandle
|
|
clientRegistrationRevision?: string
|
|
expectedCredentialRevision?: string
|
|
}) => {
|
|
this.assertActive(input.context)
|
|
if (input.source === "authorization-code") {
|
|
const pending = input.authorization ? this.authorizationRecords.get(input.authorization.id) : undefined
|
|
if (!pending || pending.handle.revision !== input.authorization?.revision) throw new Error("authorization was not active")
|
|
if (pending.handle.clientRegistrationRevision !== input.clientRegistrationRevision) {
|
|
throw new Error("client registration changed")
|
|
}
|
|
this.authorizationRecords.delete(pending.handle.id)
|
|
} else if (
|
|
!input.expectedCredentialRevision
|
|
|| input.expectedCredentialRevision !== this.credential?.revision
|
|
) {
|
|
throw new EnterpriseMcpOAuthContractError(
|
|
"MCP_OAUTH_CREDENTIAL_CHANGED",
|
|
"The OAuth credential changed while refresh was in progress.",
|
|
)
|
|
}
|
|
this.credential = {
|
|
tokens: input.tokens,
|
|
expiresAt: input.expiresAt,
|
|
revision: this.nextRevision(),
|
|
}
|
|
this.assertActive(input.context)
|
|
},
|
|
invalidate: async () => {
|
|
this.credential = undefined
|
|
this.invalidationCount += 1
|
|
},
|
|
}
|
|
|
|
seedRegistration(clientInformation: StoredOAuthClientInformation, expiresAt?: number): void {
|
|
this.registration = {
|
|
clientInformation,
|
|
revision: this.nextRevision(),
|
|
expiresAt,
|
|
source: "pre-registered",
|
|
}
|
|
}
|
|
|
|
seedCredential(tokens: StoredOAuthTokens, expiresAt?: number): void {
|
|
this.credential = { tokens, expiresAt, revision: this.nextRevision() }
|
|
}
|
|
}
|
|
|
|
function oauthMetadataFetch(issuer: string): EnterpriseMcpFetch {
|
|
const canonical = new URL(issuer)
|
|
return async (url) => {
|
|
const target = new URL(url)
|
|
if (
|
|
target.origin === canonical.origin
|
|
&& (
|
|
target.pathname.startsWith("/.well-known/oauth-authorization-server")
|
|
|| target.pathname.startsWith("/.well-known/openid-configuration")
|
|
)
|
|
) {
|
|
return Response.json({
|
|
issuer,
|
|
authorization_endpoint: `${canonical.origin}/authorize`,
|
|
token_endpoint: `${canonical.origin}/token`,
|
|
response_types_supported: ["code"],
|
|
})
|
|
}
|
|
return new Response(null, { status: 404 })
|
|
}
|
|
}
|
|
|
|
function oauthProvider(input: {
|
|
persistence: EnterpriseMcpOAuthPersistence
|
|
flow: { kind: "connect"; authorizationId?: string } | { kind: "callback"; authorizationId: string } | { kind: "runtime" }
|
|
now?: () => number
|
|
authorizationTransactionTtlMs?: number
|
|
expirationSkewMs?: number
|
|
}): EnterpriseMcpOAuthProvider {
|
|
const controller = new AbortController()
|
|
const now = input.now ?? (() => Date.now())
|
|
return new EnterpriseMcpOAuthProvider({
|
|
redirectUri: "https://den.example.test/callback",
|
|
connectionId: "connection-1",
|
|
persistence: input.persistence,
|
|
flow: input.flow,
|
|
clientName: "OpenWork",
|
|
clock: { now },
|
|
lifecycle: { expiresAt: now() + 30_000, signal: controller.signal },
|
|
authorizationTransactionTtlMs: input.authorizationTransactionTtlMs ?? 600_000,
|
|
expirationSkewMs: input.expirationSkewMs ?? 0,
|
|
fetch: async () => new Response(null, { status: 404 }),
|
|
})
|
|
}
|
|
|
|
function runtimeRejectingMcpFetch(input: {
|
|
rejectMethod: "tools/call" | "tools/list"
|
|
status: number
|
|
wwwAuthenticate?: string
|
|
}): EnterpriseMcpFetch {
|
|
return async (_url, init) => {
|
|
const body = requestText(init?.body)
|
|
if (!body) return new Response(null, { status: 202 })
|
|
const request = rpcRequestSchema.parse(JSON.parse(body))
|
|
if (request.method === "notifications/initialized") return new Response(null, { status: 202 })
|
|
if (request.method === "initialize") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
result: {
|
|
protocolVersion: "2025-06-18",
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "runtime-rejection-test", version: "1.0.0" },
|
|
},
|
|
})
|
|
}
|
|
if (request.method === input.rejectMethod) {
|
|
const headers = new Headers()
|
|
if (input.wwwAuthenticate) headers.set("www-authenticate", input.wwwAuthenticate)
|
|
return new Response(null, { status: input.status, headers })
|
|
}
|
|
if (request.method === "tools/list") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
result: { tools: [{ name: "oauth-tool", inputSchema: { type: "object", properties: {} } }] },
|
|
})
|
|
}
|
|
if (request.method === "tools/call") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
result: { content: [{ type: "text", text: "ok" }], isError: false },
|
|
})
|
|
}
|
|
return new Response(null, { status: 404 })
|
|
}
|
|
}
|
|
|
|
function oauthRuntimeConnection(id: string, persistence: EnterpriseMcpOAuthPersistence): EnterpriseMcpConnection {
|
|
return {
|
|
id,
|
|
serverUrl: "https://mcp.example.test/mcp",
|
|
authorization: { type: "oauth", persistence },
|
|
}
|
|
}
|
|
|
|
describe("enterprise MCP OAuth persistence contract", () => {
|
|
it("uses discovery or an explicit registration hint to select confidential-client authentication", () => {
|
|
const confidentialClient = {
|
|
client_id: "confidential-client",
|
|
client_secret: "confidential-secret",
|
|
}
|
|
assert.equal(selectClientAuthMethod(confidentialClient, ["client_secret_post"]), "client_secret_post")
|
|
assert.equal(selectClientAuthMethod(confidentialClient, []), "client_secret_basic")
|
|
assert.equal(selectClientAuthMethod({
|
|
...confidentialClient,
|
|
redirect_uris: ["https://den.example.test/v1/mcp-connections/oauth/callback"],
|
|
token_endpoint_auth_method: "client_secret_post",
|
|
}, []), "client_secret_post")
|
|
})
|
|
|
|
it("validates authorization-response issuers exactly before token exchange", () => {
|
|
const discoveryState = {
|
|
authorizationServerUrl: "https://identity.example.test/tenant",
|
|
authorizationServerMetadata: {
|
|
issuer: "https://identity.example.test/tenant",
|
|
authorization_response_iss_parameter_supported: true,
|
|
},
|
|
resourceMetadata: {
|
|
authorization_servers: ["https://identity.example.test/tenant"],
|
|
},
|
|
}
|
|
|
|
assert.doesNotThrow(() => validateMcpAuthorizationResponseIssuer({
|
|
expectedIssuer: "https://identity.example.test/tenant",
|
|
discoveryState,
|
|
responseIssuer: "https://identity.example.test/tenant",
|
|
}))
|
|
assert.throws(() => validateMcpAuthorizationResponseIssuer({
|
|
expectedIssuer: "https://identity.example.test/tenant",
|
|
discoveryState,
|
|
responseIssuer: "https://identity.example.test/tenant/",
|
|
}), (error: unknown) => error instanceof EnterpriseMcpOAuthContractError
|
|
&& error.code === "MCP_OAUTH_ISSUER_MISMATCH")
|
|
assert.throws(() => validateMcpAuthorizationResponseIssuer({
|
|
expectedIssuer: "https://identity.example.test/tenant",
|
|
discoveryState,
|
|
}), (error: unknown) => error instanceof EnterpriseMcpOAuthContractError
|
|
&& error.code === "MCP_OAUTH_ISSUER_MISMATCH")
|
|
})
|
|
|
|
it("uses a distinct redirect URI as the mix-up defense for servers that do not advertise response issuers", () => {
|
|
const expectedIssuer = "https://identity.example.test/tenant"
|
|
const discoveryState = {
|
|
authorizationServerUrl: expectedIssuer,
|
|
authorizationServerMetadata: {
|
|
issuer: expectedIssuer,
|
|
authorization_response_iss_parameter_supported: false,
|
|
},
|
|
resourceMetadata: { authorization_servers: [expectedIssuer] },
|
|
}
|
|
|
|
assert.deepEqual(validateMcpAuthorizationResponseIssuer({
|
|
expectedIssuer,
|
|
discoveryState,
|
|
mixUpDefense: "distinct-redirect-uri",
|
|
}), { defense: "distinct-redirect-uri" })
|
|
assert.deepEqual(validateMcpAuthorizationResponseIssuer({
|
|
expectedIssuer,
|
|
discoveryState,
|
|
responseIssuer: "stytch.com/project-live-provider-value",
|
|
mixUpDefense: "distinct-redirect-uri",
|
|
}), {
|
|
defense: "distinct-redirect-uri",
|
|
ignoredResponseIssuer: "stytch.com/project-live-provider-value",
|
|
})
|
|
})
|
|
|
|
it("supports a pinned authorization transaction when a provider omits response issuers", () => {
|
|
const expectedIssuer = "https://identity.example.test/tenant"
|
|
const discoveryState = {
|
|
authorizationServerUrl: expectedIssuer,
|
|
authorizationServerMetadata: {
|
|
issuer: expectedIssuer,
|
|
authorization_response_iss_parameter_supported: false,
|
|
},
|
|
resourceMetadata: { authorization_servers: [expectedIssuer] },
|
|
}
|
|
|
|
assert.deepEqual(validateMcpAuthorizationResponseIssuer({
|
|
expectedIssuer,
|
|
discoveryState,
|
|
mixUpDefense: "pinned-transaction",
|
|
}), { defense: "pinned-transaction" })
|
|
assert.throws(() => validateMcpAuthorizationResponseIssuer({
|
|
expectedIssuer,
|
|
discoveryState,
|
|
responseIssuer: "https://attacker.example.test",
|
|
mixUpDefense: "pinned-transaction",
|
|
}), (error: unknown) => error instanceof EnterpriseMcpOAuthContractError
|
|
&& error.code === "MCP_OAUTH_ISSUER_MISMATCH")
|
|
})
|
|
|
|
it("never lets PKCE or an isolated callback override advertised RFC 9207 support", () => {
|
|
const expectedIssuer = "https://identity.example.test/tenant"
|
|
const discoveryState = {
|
|
authorizationServerUrl: expectedIssuer,
|
|
authorizationServerMetadata: {
|
|
issuer: expectedIssuer,
|
|
authorization_response_iss_parameter_supported: true,
|
|
code_challenge_methods_supported: ["S256"],
|
|
},
|
|
resourceMetadata: { authorization_servers: [expectedIssuer] },
|
|
}
|
|
|
|
assert.throws(() => validateMcpAuthorizationResponseIssuer({
|
|
expectedIssuer,
|
|
discoveryState,
|
|
responseIssuer: "https://attacker.example.test",
|
|
mixUpDefense: "distinct-redirect-uri",
|
|
}), (error: unknown) => error instanceof EnterpriseMcpOAuthContractError
|
|
&& error.code === "MCP_OAUTH_ISSUER_MISMATCH")
|
|
})
|
|
|
|
it("replaces resource-alias endpoints with strictly discovered canonical metadata", async () => {
|
|
const alias = "https://api.salesforce.example:443/platform/mcp/v1/platform/sobject-all"
|
|
const canonicalIssuer = "https://login.salesforce.example"
|
|
const discoveryState: OAuthDiscoveryState = {
|
|
authorizationServerUrl: alias,
|
|
authorizationServerMetadata: {
|
|
issuer: canonicalIssuer,
|
|
authorization_endpoint: "https://attacker.example.test/authorize",
|
|
token_endpoint: "https://attacker.example.test/token",
|
|
response_types_supported: ["code"],
|
|
},
|
|
resourceMetadata: {
|
|
resource: alias,
|
|
authorization_servers: [alias],
|
|
},
|
|
}
|
|
const persistence = new MemoryOAuthPersistence()
|
|
const provider = new EnterpriseMcpOAuthProvider({
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
connectionId: "connection-1",
|
|
persistence,
|
|
flow: { kind: "connect", authorizationId: "signed-state" },
|
|
clientName: "OpenWork",
|
|
clock: { now: () => Date.now() },
|
|
lifecycle: { expiresAt: Date.now() + 30_000, signal: new AbortController().signal },
|
|
authorizationTransactionTtlMs: 600_000,
|
|
expirationSkewMs: 0,
|
|
fetch: oauthMetadataFetch(canonicalIssuer),
|
|
oauthConfiguration: {
|
|
applicationType: "web",
|
|
authorizationServerIssuer: canonicalIssuer,
|
|
},
|
|
})
|
|
|
|
await assert.doesNotReject(provider.saveDiscoveryState(discoveryState))
|
|
assert.equal(persistence.discoveryState?.authorizationServerUrl, canonicalIssuer)
|
|
assert.equal(persistence.discoveryState?.authorizationServerMetadata?.authorization_endpoint, `${canonicalIssuer}/authorize`)
|
|
assert.equal(persistence.discoveryState?.authorizationServerMetadata?.token_endpoint, `${canonicalIssuer}/token`)
|
|
const reloadedProvider = new EnterpriseMcpOAuthProvider({
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
connectionId: "connection-1",
|
|
persistence,
|
|
flow: { kind: "runtime" },
|
|
clientName: "OpenWork",
|
|
clock: { now: () => Date.now() },
|
|
lifecycle: { expiresAt: Date.now() + 30_000, signal: new AbortController().signal },
|
|
authorizationTransactionTtlMs: 600_000,
|
|
expirationSkewMs: 0,
|
|
fetch: async () => {
|
|
throw new Error("verified cached metadata must not be rediscovered")
|
|
},
|
|
oauthConfiguration: {
|
|
applicationType: "web",
|
|
authorizationServerIssuer: canonicalIssuer,
|
|
},
|
|
})
|
|
assert.equal((await reloadedProvider.discoveryState())?.authorizationServerUrl, canonicalIssuer)
|
|
assert.doesNotThrow(() => validateMcpAuthorizationResponseIssuer({
|
|
expectedIssuer: canonicalIssuer,
|
|
discoveryState,
|
|
responseIssuer: canonicalIssuer,
|
|
}))
|
|
})
|
|
|
|
it("binds an equivalent root discovery alias while keeping callback issuer checks exact", async () => {
|
|
const canonicalIssuer = "https://vercel.example"
|
|
const discoveryState: OAuthDiscoveryState = {
|
|
authorizationServerUrl: "https://mcp.vercel.example",
|
|
authorizationServerMetadata: {
|
|
issuer: canonicalIssuer,
|
|
authorization_endpoint: `${canonicalIssuer}/oauth/authorize`,
|
|
token_endpoint: `${canonicalIssuer}/oauth/token`,
|
|
response_types_supported: ["code"],
|
|
},
|
|
resourceMetadata: {
|
|
resource: "https://mcp.vercel.example/",
|
|
authorization_servers: ["https://mcp.vercel.example"],
|
|
},
|
|
}
|
|
const persistence = new MemoryOAuthPersistence()
|
|
const provider = new EnterpriseMcpOAuthProvider({
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
connectionId: "connection-1",
|
|
persistence,
|
|
flow: { kind: "connect", authorizationId: "signed-state" },
|
|
clientName: "OpenWork",
|
|
clock: { now: () => Date.now() },
|
|
lifecycle: { expiresAt: Date.now() + 30_000, signal: new AbortController().signal },
|
|
authorizationTransactionTtlMs: 600_000,
|
|
expirationSkewMs: 0,
|
|
fetch: oauthMetadataFetch(canonicalIssuer),
|
|
oauthConfiguration: {
|
|
applicationType: "web",
|
|
authorizationServerIssuer: canonicalIssuer,
|
|
},
|
|
})
|
|
|
|
await assert.doesNotReject(provider.saveDiscoveryState(discoveryState))
|
|
assert.doesNotThrow(() => validateMcpAuthorizationResponseIssuer({
|
|
expectedIssuer: canonicalIssuer,
|
|
discoveryState,
|
|
responseIssuer: canonicalIssuer,
|
|
}))
|
|
assert.throws(() => validateMcpAuthorizationResponseIssuer({
|
|
expectedIssuer: canonicalIssuer,
|
|
discoveryState,
|
|
responseIssuer: `${canonicalIssuer}/`,
|
|
}), (error: unknown) => error instanceof EnterpriseMcpOAuthContractError
|
|
&& error.code === "MCP_OAUTH_ISSUER_MISMATCH")
|
|
})
|
|
|
|
it("binds an equivalent authorization-server root alias while keeping callback issuer checks exact", async () => {
|
|
const canonicalIssuer = "https://api.close.example"
|
|
const discoveryState: OAuthDiscoveryState = {
|
|
authorizationServerUrl: `${canonicalIssuer}/`,
|
|
authorizationServerMetadata: {
|
|
issuer: canonicalIssuer,
|
|
authorization_endpoint: "https://app.close.example/oauth2/authorize/",
|
|
token_endpoint: `${canonicalIssuer}/oauth2/token/`,
|
|
response_types_supported: ["code"],
|
|
},
|
|
resourceMetadata: {
|
|
resource: "https://mcp.close.example/",
|
|
authorization_servers: [`${canonicalIssuer}/`],
|
|
},
|
|
}
|
|
const persistence = new MemoryOAuthPersistence()
|
|
const provider = new EnterpriseMcpOAuthProvider({
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
connectionId: "connection-1",
|
|
persistence,
|
|
flow: { kind: "connect", authorizationId: "signed-state" },
|
|
clientName: "OpenWork",
|
|
clock: { now: () => Date.now() },
|
|
lifecycle: { expiresAt: Date.now() + 30_000, signal: new AbortController().signal },
|
|
authorizationTransactionTtlMs: 600_000,
|
|
expirationSkewMs: 0,
|
|
fetch: oauthMetadataFetch(canonicalIssuer),
|
|
oauthConfiguration: {
|
|
applicationType: "web",
|
|
authorizationServerIssuer: canonicalIssuer,
|
|
},
|
|
})
|
|
|
|
await assert.doesNotReject(provider.saveDiscoveryState(discoveryState))
|
|
assert.doesNotThrow(() => validateMcpAuthorizationResponseIssuer({
|
|
expectedIssuer: canonicalIssuer,
|
|
discoveryState,
|
|
responseIssuer: canonicalIssuer,
|
|
}))
|
|
assert.throws(() => validateMcpAuthorizationResponseIssuer({
|
|
expectedIssuer: canonicalIssuer,
|
|
discoveryState,
|
|
responseIssuer: `${canonicalIssuer}/`,
|
|
}), (error: unknown) => error instanceof EnterpriseMcpOAuthContractError
|
|
&& error.code === "MCP_OAUTH_ISSUER_MISMATCH")
|
|
})
|
|
|
|
it("does not normalize trailing slashes on non-root authorization-server issuers", async () => {
|
|
const canonicalIssuer = "https://identity.example.test/tenant"
|
|
const persistence = new MemoryOAuthPersistence()
|
|
const provider = new EnterpriseMcpOAuthProvider({
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
connectionId: "connection-1",
|
|
persistence,
|
|
flow: { kind: "connect", authorizationId: "signed-state" },
|
|
clientName: "OpenWork",
|
|
clock: { now: () => Date.now() },
|
|
lifecycle: { expiresAt: Date.now() + 30_000, signal: new AbortController().signal },
|
|
authorizationTransactionTtlMs: 600_000,
|
|
expirationSkewMs: 0,
|
|
fetch: oauthMetadataFetch(canonicalIssuer),
|
|
oauthConfiguration: {
|
|
applicationType: "web",
|
|
authorizationServerIssuer: canonicalIssuer,
|
|
},
|
|
})
|
|
|
|
await assert.rejects(provider.saveDiscoveryState({
|
|
authorizationServerUrl: `${canonicalIssuer}/`,
|
|
authorizationServerMetadata: {
|
|
issuer: canonicalIssuer,
|
|
authorization_endpoint: `${canonicalIssuer}/authorize`,
|
|
token_endpoint: `${canonicalIssuer}/token`,
|
|
response_types_supported: ["code"],
|
|
},
|
|
resourceMetadata: {
|
|
resource: "https://mcp.example.test/",
|
|
authorization_servers: [`${canonicalIssuer}/`],
|
|
},
|
|
}), (error: unknown) => error instanceof EnterpriseMcpOAuthContractError
|
|
&& error.code === "MCP_OAUTH_ISSUER_MISMATCH")
|
|
})
|
|
|
|
it("rejects a non-HTTPS client metadata document before OAuth performs network work", async () => {
|
|
let fetchCount = 0
|
|
const client = createEnterpriseMcpClient({
|
|
fetch: async () => {
|
|
fetchCount += 1
|
|
return new Response(null, { status: 500 })
|
|
},
|
|
})
|
|
await assert.rejects(client.connect({
|
|
connection: {
|
|
id: "invalid-cimd-url",
|
|
serverUrl: "https://mcp.example.test/mcp",
|
|
authorization: {
|
|
type: "oauth",
|
|
persistence: new MemoryOAuthPersistence(),
|
|
configuration: {
|
|
applicationType: "web",
|
|
clientMetadataUrl: "http://den.example.test/oauth/client-metadata.json",
|
|
},
|
|
},
|
|
},
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
authorizationId: "signed-state",
|
|
}), (error: unknown) => error instanceof EnterpriseMcpClientError
|
|
&& error.code === "MCP_CONFIGURATION_FAILED")
|
|
assert.equal(fetchCount, 0)
|
|
})
|
|
|
|
it("publishes a web client identity and records CIMD registration provenance", async () => {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
const provider = new EnterpriseMcpOAuthProvider({
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
connectionId: "connection-1",
|
|
persistence,
|
|
flow: { kind: "connect", authorizationId: "signed-state" },
|
|
clientName: "OpenWork",
|
|
clock: { now: () => Date.now() },
|
|
lifecycle: { expiresAt: Date.now() + 30_000, signal: new AbortController().signal },
|
|
authorizationTransactionTtlMs: 600_000,
|
|
expirationSkewMs: 0,
|
|
fetch: async () => new Response(null, { status: 404 }),
|
|
oauthConfiguration: {
|
|
applicationType: "web",
|
|
clientMetadataUrl: "https://den.example.test/oauth/client-metadata.json",
|
|
requestedScopes: ["tools.read"],
|
|
},
|
|
})
|
|
|
|
assert.equal(provider.clientMetadata.application_type, "web")
|
|
assert.deepEqual(provider.clientMetadata.redirect_uris, ["https://den.example.test/v1/mcp-connections/oauth/callback"])
|
|
assert.equal(provider.clientMetadata.scope, "tools.read")
|
|
await provider.saveClientInformation({ client_id: "https://den.example.test/oauth/client-metadata.json" })
|
|
assert.equal(persistence.registration?.source, "client-metadata")
|
|
})
|
|
|
|
it("rejects discovery state that does not match the selected issuer", async () => {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
const provider = new EnterpriseMcpOAuthProvider({
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
connectionId: "connection-1",
|
|
persistence,
|
|
flow: { kind: "connect", authorizationId: "signed-state" },
|
|
clientName: "OpenWork",
|
|
clock: { now: () => Date.now() },
|
|
lifecycle: { expiresAt: Date.now() + 30_000, signal: new AbortController().signal },
|
|
authorizationTransactionTtlMs: 600_000,
|
|
expirationSkewMs: 0,
|
|
fetch: oauthMetadataFetch("https://identity.example.test/tenant-a"),
|
|
oauthConfiguration: {
|
|
applicationType: "web",
|
|
authorizationServerIssuer: "https://identity.example.test/tenant-a",
|
|
},
|
|
})
|
|
|
|
await assert.rejects(
|
|
provider.saveDiscoveryState({
|
|
authorizationServerUrl: "https://identity.example.test/tenant-b",
|
|
authorizationServerMetadata: {
|
|
issuer: "https://identity.example.test/tenant-b",
|
|
authorization_endpoint: "https://identity.example.test/authorize",
|
|
token_endpoint: "https://identity.example.test/token",
|
|
response_types_supported: ["code"],
|
|
},
|
|
resourceMetadata: {
|
|
resource: "https://mcp.example.test",
|
|
authorization_servers: ["https://identity.example.test/tenant-a"],
|
|
},
|
|
}),
|
|
(error: unknown) => error instanceof EnterpriseMcpOAuthContractError
|
|
&& error.code === "MCP_OAUTH_ISSUER_MISMATCH",
|
|
)
|
|
assert.equal(persistence.discoveryState, undefined)
|
|
})
|
|
|
|
it("preserves a cached issuer mismatch through the modern discovery probe", async () => {
|
|
const selectedIssuer = "https://mcp.example.test"
|
|
const canonicalIssuer = "https://identity.example.test"
|
|
const persistence = new MemoryOAuthPersistence()
|
|
persistence.discoveryState = {
|
|
authorizationServerUrl: canonicalIssuer,
|
|
authorizationServerMetadata: {
|
|
issuer: canonicalIssuer,
|
|
authorization_endpoint: `${canonicalIssuer}/authorize`,
|
|
token_endpoint: `${canonicalIssuer}/token`,
|
|
response_types_supported: ["code"],
|
|
},
|
|
resourceMetadata: {
|
|
resource: selectedIssuer,
|
|
authorization_servers: [selectedIssuer],
|
|
},
|
|
}
|
|
const client = createEnterpriseMcpClient({
|
|
fetch: async () => new Response(null, {
|
|
status: 401,
|
|
headers: {
|
|
"www-authenticate": `Bearer resource_metadata="${selectedIssuer}/.well-known/oauth-protected-resource"`,
|
|
},
|
|
}),
|
|
})
|
|
|
|
await assert.rejects(client.connect({
|
|
connection: {
|
|
id: "issuer-mismatch-probe",
|
|
serverUrl: selectedIssuer,
|
|
authorization: {
|
|
type: "oauth",
|
|
persistence,
|
|
configuration: {
|
|
applicationType: "web",
|
|
authorizationServerIssuer: selectedIssuer,
|
|
},
|
|
},
|
|
},
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
authorizationId: "signed-state",
|
|
}), (error: unknown) => error instanceof EnterpriseMcpClientError
|
|
&& error.cause instanceof EnterpriseMcpOAuthContractError
|
|
&& error.cause.code === "MCP_OAUTH_ISSUER_MISMATCH")
|
|
})
|
|
|
|
it("returns a typed configuration requirement when neither CIMD nor DCR is advertised", async () => {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
const provider = oauthProvider({ persistence, flow: { kind: "connect", authorizationId: "signed-state" } })
|
|
await provider.saveDiscoveryState({
|
|
authorizationServerUrl: "https://identity.example.test",
|
|
authorizationServerMetadata: {
|
|
issuer: "https://identity.example.test",
|
|
authorization_endpoint: "https://identity.example.test/authorize",
|
|
token_endpoint: "https://identity.example.test/token",
|
|
response_types_supported: ["code"],
|
|
},
|
|
resourceMetadata: {
|
|
resource: "https://mcp.example.test",
|
|
authorization_servers: ["https://identity.example.test"],
|
|
},
|
|
})
|
|
|
|
await assert.rejects(
|
|
provider.clientInformation(),
|
|
(error: unknown) => error instanceof EnterpriseMcpOAuthContractError
|
|
&& error.code === "MCP_OAUTH_CONFIGURATION_REQUIRED",
|
|
)
|
|
})
|
|
|
|
it("round-trips state, client registration, tokens, and PKCE through the injected store", async () => {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
const controller = new AbortController()
|
|
const provider = new EnterpriseMcpOAuthProvider({
|
|
redirectUri: "https://den.example.test/callback",
|
|
connectionId: "connection-1",
|
|
persistence,
|
|
flow: { kind: "connect", authorizationId: "signed-state" },
|
|
clientName: "OpenWork",
|
|
clock: { now: () => Date.now() },
|
|
lifecycle: { expiresAt: Date.now() + 30_000, signal: controller.signal },
|
|
authorizationTransactionTtlMs: 600_000,
|
|
expirationSkewMs: 0,
|
|
fetch: async () => new Response(null, { status: 404 }),
|
|
})
|
|
|
|
assert.equal(provider.state(), "signed-state")
|
|
assert.equal(provider.redirectUrl, "https://den.example.test/callback")
|
|
await provider.saveClientInformation({ client_id: "registered-client" })
|
|
assert.equal((await provider.clientInformation())?.client_id, "registered-client")
|
|
await provider.saveCodeVerifier("pkce-verifier")
|
|
assert.equal(persistence.authorizationRecords.get("signed-state")?.codeVerifier, "pkce-verifier")
|
|
provider.redirectToAuthorization(new URL("https://identity.example.test/authorize"))
|
|
assert.equal(provider.authorizeUrl, "https://identity.example.test/authorize")
|
|
})
|
|
|
|
it("completes discovery, dynamic registration, PKCE exchange, and authenticated MCP initialization", async () => {
|
|
const server = await startOAuthMcpServer()
|
|
try {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
const events: EnterpriseMcpDiagnosticEvent[] = []
|
|
const client = createEnterpriseMcpClient({ fetch, diagnosticSink: (event) => events.push(event) })
|
|
const connection: EnterpriseMcpConnection = {
|
|
id: "oauth-connection",
|
|
serverUrl: `${server.origin}/mcp`,
|
|
authorization: { type: "oauth", persistence },
|
|
}
|
|
const redirectUri = "https://den.example.test/v1/mcp-connections/oauth-connection/connect/callback"
|
|
const started = await client.connect({ connection, redirectUri, authorizationId: "signed-den-state" })
|
|
assert.equal(started.status, "needs_auth")
|
|
if (started.status !== "needs_auth") throw new Error("Expected OAuth authorization to be required.")
|
|
const authorizeUrl = new URL(started.authorizeUrl)
|
|
assert.equal(authorizeUrl.searchParams.get("state"), "signed-den-state")
|
|
assert.equal(authorizeUrl.searchParams.get("client_id"), "enterprise-test-client")
|
|
assert.equal(authorizeUrl.searchParams.get("scope"), "tools.read")
|
|
assert.equal(server.registration()?.application_type, "web")
|
|
assert.equal(server.registration()?.scope, "tools.read")
|
|
assert.ok(persistence.authorizationRecords.get("signed-den-state")?.codeVerifier)
|
|
|
|
await client.completeAuthorization({
|
|
connection,
|
|
redirectUri,
|
|
code: "approved-code",
|
|
authorizationId: "signed-den-state",
|
|
})
|
|
assert.equal(persistence.registration?.clientInformation.issuer, server.origin)
|
|
assert.equal(persistence.credential?.tokens.access_token, "enterprise-access-token")
|
|
assert.equal(persistence.credential?.tokens.issuer, server.origin)
|
|
assert.equal(persistence.authorizationRecords.size, 0)
|
|
assert.deepEqual(await client.connect({
|
|
connection,
|
|
redirectUri,
|
|
authorizationId: "signed-den-state-after-callback",
|
|
}), { status: "connected" })
|
|
const tools = await client.listTools({ connection, redirectUri })
|
|
assert.equal(tools[0]?.name, "oauth-tool")
|
|
for (const phase of [
|
|
"oauth-resource-discovery",
|
|
"oauth-server-discovery",
|
|
"oauth-client-registration",
|
|
"oauth-token-exchange",
|
|
"mcp-initialize",
|
|
"mcp-tool-discovery",
|
|
]) {
|
|
assert.ok(events.some((event) => event.requestPhase === phase), `Expected a diagnostic event for ${phase}`)
|
|
}
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
it("rejects stored OAuth clients and tokens stamped for another issuer", async () => {
|
|
const selectedIssuer = "https://identity.example.test"
|
|
const persistence = new MemoryOAuthPersistence()
|
|
persistence.seedRegistration({
|
|
client_id: "wrong-issuer-client",
|
|
issuer: "https://attacker.example.test",
|
|
})
|
|
persistence.seedCredential({
|
|
access_token: "wrong-issuer-token",
|
|
token_type: "Bearer",
|
|
issuer: "https://attacker.example.test",
|
|
})
|
|
const provider = new EnterpriseMcpOAuthProvider({
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
connectionId: "connection-1",
|
|
persistence,
|
|
flow: { kind: "runtime" },
|
|
clientName: "OpenWork",
|
|
clock: { now: () => Date.now() },
|
|
lifecycle: { expiresAt: Date.now() + 30_000, signal: new AbortController().signal },
|
|
authorizationTransactionTtlMs: 600_000,
|
|
expirationSkewMs: 0,
|
|
fetch: oauthMetadataFetch(selectedIssuer),
|
|
oauthConfiguration: {
|
|
applicationType: "web",
|
|
authorizationServerIssuer: selectedIssuer,
|
|
},
|
|
})
|
|
|
|
await assert.rejects(provider.clientInformation({ issuer: selectedIssuer }), (error: unknown) => (
|
|
error instanceof EnterpriseMcpOAuthContractError
|
|
&& error.code === "MCP_OAUTH_ISSUER_MISMATCH"
|
|
))
|
|
await assert.rejects(provider.tokens({ issuer: selectedIssuer }), (error: unknown) => (
|
|
error instanceof EnterpriseMcpOAuthContractError
|
|
&& error.code === "MCP_OAUTH_ISSUER_MISMATCH"
|
|
))
|
|
})
|
|
|
|
it("accepts stored credentials whose issuer is the root trailing-slash alias of the selected issuer", async () => {
|
|
const selectedIssuer = "https://identity.example.test/"
|
|
const canonicalIssuer = "https://identity.example.test"
|
|
const persistence = new MemoryOAuthPersistence()
|
|
persistence.seedRegistration({
|
|
client_id: "canonical-issuer-client",
|
|
issuer: canonicalIssuer,
|
|
})
|
|
persistence.seedCredential({
|
|
access_token: "canonical-issuer-token",
|
|
token_type: "Bearer",
|
|
issuer: canonicalIssuer,
|
|
})
|
|
const provider = new EnterpriseMcpOAuthProvider({
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
connectionId: "connection-1",
|
|
persistence,
|
|
flow: { kind: "runtime" },
|
|
clientName: "OpenWork",
|
|
clock: { now: () => Date.now() },
|
|
lifecycle: { expiresAt: Date.now() + 30_000, signal: new AbortController().signal },
|
|
authorizationTransactionTtlMs: 600_000,
|
|
expirationSkewMs: 0,
|
|
fetch: oauthMetadataFetch(canonicalIssuer),
|
|
oauthConfiguration: {
|
|
applicationType: "web",
|
|
authorizationServerIssuer: selectedIssuer,
|
|
},
|
|
})
|
|
|
|
assert.equal((await provider.clientInformation({ issuer: canonicalIssuer }))?.client_id, "canonical-issuer-client")
|
|
assert.equal((await provider.tokens({ issuer: canonicalIssuer }))?.access_token, "canonical-issuer-token")
|
|
})
|
|
|
|
it("keeps persisted discovery state when canonical metadata is temporarily unavailable", async () => {
|
|
const selectedIssuer = "https://identity.example.test"
|
|
const persistence = new MemoryOAuthPersistence()
|
|
const state: OAuthDiscoveryState = {
|
|
authorizationServerUrl: selectedIssuer,
|
|
authorizationServerMetadata: {
|
|
issuer: selectedIssuer,
|
|
authorization_endpoint: `${selectedIssuer}/authorize`,
|
|
token_endpoint: `${selectedIssuer}/token`,
|
|
response_types_supported: ["code"],
|
|
},
|
|
}
|
|
persistence.discoveryState = state
|
|
const provider = new EnterpriseMcpOAuthProvider({
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
connectionId: "connection-1",
|
|
persistence,
|
|
flow: { kind: "runtime" },
|
|
clientName: "OpenWork",
|
|
clock: { now: () => Date.now() },
|
|
lifecycle: { expiresAt: Date.now() + 30_000, signal: new AbortController().signal },
|
|
authorizationTransactionTtlMs: 600_000,
|
|
expirationSkewMs: 0,
|
|
fetch: async () => {
|
|
throw new TypeError("fetch failed")
|
|
},
|
|
oauthConfiguration: {
|
|
applicationType: "web",
|
|
authorizationServerIssuer: selectedIssuer,
|
|
},
|
|
})
|
|
|
|
await assert.rejects(provider.saveDiscoveryState(state), (error: unknown) => (
|
|
error instanceof TypeError && error.message === "fetch failed"
|
|
))
|
|
assert.deepEqual(persistence.discoveryState, state)
|
|
})
|
|
|
|
it("falls back to advertised scopes when the challenge and requested scopes are empty", async () => {
|
|
const server = await startOAuthMcpServer({ scopeLessChallenge: true })
|
|
try {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
const client = createEnterpriseMcpClient({ fetch })
|
|
const connection: EnterpriseMcpConnection = {
|
|
id: "oauth-advertised-scope-fallback",
|
|
serverUrl: `${server.origin}/mcp`,
|
|
authorization: {
|
|
type: "oauth",
|
|
persistence,
|
|
configuration: { applicationType: "web", requestedScopes: [] },
|
|
},
|
|
}
|
|
const started = await client.connect({
|
|
connection,
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
authorizationId: "signed-fallback-state",
|
|
})
|
|
assert.equal(started.status, "needs_auth")
|
|
if (started.status !== "needs_auth") throw new Error("Expected OAuth authorization to be required.")
|
|
assert.equal(new URL(started.authorizeUrl).searchParams.get("scope"), "tools.read")
|
|
assert.equal(server.registration()?.scope, "tools.read")
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
it("prefers protected-resource scopes over unrelated authorization-server scopes", async () => {
|
|
const server = await startOAuthMcpServer({
|
|
scopeLessChallenge: true,
|
|
resourceAdvertisedScopes: ["tools.read"],
|
|
authorizationAdvertisedScopes: ["openid", "profile"],
|
|
})
|
|
try {
|
|
const client = createEnterpriseMcpClient({ fetch })
|
|
const connection: EnterpriseMcpConnection = {
|
|
id: "oauth-resource-scope-fallback",
|
|
serverUrl: `${server.origin}/mcp`,
|
|
authorization: {
|
|
type: "oauth",
|
|
persistence: new MemoryOAuthPersistence(),
|
|
configuration: { applicationType: "web", requestedScopes: [] },
|
|
},
|
|
}
|
|
const started = await client.connect({
|
|
connection,
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
authorizationId: "signed-resource-scope-state",
|
|
})
|
|
assert.equal(started.status, "needs_auth")
|
|
if (started.status !== "needs_auth") throw new Error("Expected OAuth authorization to be required.")
|
|
assert.equal(new URL(started.authorizeUrl).searchParams.get("scope"), "tools.read")
|
|
assert.equal(server.registration()?.scope, "tools.read")
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
it("keeps an administrator's selected scopes narrower than a scope-less provider advertisement", async () => {
|
|
const server = await startOAuthMcpServer({
|
|
scopeLessChallenge: true,
|
|
resourceAdvertisedScopes: ["tools.read", "tools.write"],
|
|
authorizationAdvertisedScopes: ["openid", "profile"],
|
|
})
|
|
try {
|
|
const client = createEnterpriseMcpClient({ fetch })
|
|
const connection: EnterpriseMcpConnection = {
|
|
id: "oauth-selected-scope",
|
|
serverUrl: `${server.origin}/mcp`,
|
|
authorization: {
|
|
type: "oauth",
|
|
persistence: new MemoryOAuthPersistence(),
|
|
configuration: { applicationType: "web", requestedScopes: ["tools.read"] },
|
|
},
|
|
}
|
|
const started = await client.connect({
|
|
connection,
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
authorizationId: "signed-selected-scope-state",
|
|
})
|
|
assert.equal(started.status, "needs_auth")
|
|
if (started.status !== "needs_auth") throw new Error("Expected OAuth authorization to be required.")
|
|
assert.equal(new URL(started.authorizeUrl).searchParams.get("scope"), "tools.read")
|
|
assert.equal(server.registration()?.scope, "tools.read")
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
it("rejects selected scopes that neither the resource nor authorization server advertises", async () => {
|
|
const server = await startOAuthMcpServer({
|
|
scopeLessChallenge: true,
|
|
resourceAdvertisedScopes: ["tools.read"],
|
|
authorizationAdvertisedScopes: ["openid", "profile"],
|
|
})
|
|
try {
|
|
const client = createEnterpriseMcpClient({ fetch })
|
|
const connection: EnterpriseMcpConnection = {
|
|
id: "oauth-unsupported-selected-scope",
|
|
serverUrl: `${server.origin}/mcp`,
|
|
authorization: {
|
|
type: "oauth",
|
|
persistence: new MemoryOAuthPersistence(),
|
|
configuration: { applicationType: "web", requestedScopes: ["admin.write"] },
|
|
},
|
|
}
|
|
await assert.rejects(client.connect({
|
|
connection,
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
authorizationId: "signed-unsupported-scope-state",
|
|
}), (error: unknown) => error instanceof EnterpriseMcpClientError
|
|
&& error.cause instanceof Error
|
|
&& error.cause.message.includes("selected OAuth scope is not advertised"))
|
|
assert.equal(server.registration(), null)
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
it("uses a client metadata document before an advertised dynamic registration endpoint", async () => {
|
|
const server = await startOAuthMcpServer({ clientMetadataSupported: true })
|
|
try {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
const client = createEnterpriseMcpClient({ fetch })
|
|
const metadataUrl = "https://den.example.test/oauth/client-metadata.json"
|
|
const connection: EnterpriseMcpConnection = {
|
|
id: "oauth-cimd-priority",
|
|
serverUrl: `${server.origin}/mcp`,
|
|
authorization: {
|
|
type: "oauth",
|
|
persistence,
|
|
configuration: {
|
|
applicationType: "web",
|
|
clientMetadataUrl: metadataUrl,
|
|
requestedScopes: ["tools.read"],
|
|
},
|
|
},
|
|
}
|
|
|
|
const started = await client.connect({
|
|
connection,
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
authorizationId: "signed-cimd-state",
|
|
})
|
|
assert.equal(started.status, "needs_auth")
|
|
if (started.status !== "needs_auth") throw new Error("Expected OAuth authorization to be required.")
|
|
assert.equal(new URL(started.authorizeUrl).searchParams.get("client_id"), metadataUrl)
|
|
assert.equal(server.registration(), null)
|
|
assert.equal(persistence.registration?.source, "client-metadata")
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
it("starts OAuth when initialize is public but tool discovery requires authorization", async () => {
|
|
const origin = "https://api.descript.example"
|
|
const metadataUrl = "https://den.example.test/oauth/client-metadata.json"
|
|
const fetch: EnterpriseMcpFetch = async (url, init) => {
|
|
const target = new URL(url)
|
|
if (target.pathname === "/.well-known/oauth-protected-resource/v2/mcp") {
|
|
return Response.json({
|
|
resource: `${origin}/v2/mcp`,
|
|
authorization_servers: [origin],
|
|
})
|
|
}
|
|
if (target.pathname === "/.well-known/oauth-authorization-server") {
|
|
return Response.json({
|
|
issuer: origin,
|
|
authorization_endpoint: `${origin}/oauth/authorize`,
|
|
token_endpoint: `${origin}/oauth/token`,
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
client_id_metadata_document_supported: true,
|
|
})
|
|
}
|
|
if (target.pathname === "/v2/mcp") {
|
|
const body = typeof init?.body === "string" ? init.body : ""
|
|
if (!body) return new Response(null, { status: 202 })
|
|
const request = rpcRequestSchema.parse(JSON.parse(body))
|
|
if (request.method === "initialize") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
result: {
|
|
protocolVersion: "2025-06-18",
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "descript-style-test", version: "1.0.0" },
|
|
},
|
|
})
|
|
}
|
|
if (request.method === "notifications/initialized") return new Response(null, { status: 202 })
|
|
if (request.method === "tools/list") {
|
|
return new Response(null, {
|
|
status: 401,
|
|
headers: {
|
|
"www-authenticate": `Bearer resource_metadata=\"${origin}/.well-known/oauth-protected-resource/v2/mcp\"`,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
return new Response(null, { status: 404 })
|
|
}
|
|
const persistence = new MemoryOAuthPersistence()
|
|
const client = createEnterpriseMcpClient({ fetch })
|
|
const connection: EnterpriseMcpConnection = {
|
|
id: "oauth-public-initialize",
|
|
serverUrl: `${origin}/v2/mcp`,
|
|
authorization: {
|
|
type: "oauth",
|
|
persistence,
|
|
configuration: {
|
|
applicationType: "web",
|
|
clientMetadataUrl: metadataUrl,
|
|
},
|
|
},
|
|
}
|
|
|
|
const started = await client.connect({
|
|
connection,
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
authorizationId: "signed-descript-state",
|
|
})
|
|
|
|
assert.equal(started.status, "needs_auth")
|
|
if (started.status !== "needs_auth") throw new Error("Expected OAuth authorization to be required.")
|
|
assert.equal(new URL(started.authorizeUrl).searchParams.get("client_id"), metadataUrl)
|
|
assert.equal(persistence.registration?.source, "client-metadata")
|
|
})
|
|
|
|
it("starts OAuth when initialize and tool discovery are both public but no member credential exists", async () => {
|
|
const resourceOrigin = "https://bigquery.googleapis.test"
|
|
const authorizationOrigin = "https://accounts.google.test"
|
|
const mcpMethods: string[] = []
|
|
const fetch: EnterpriseMcpFetch = async (url, init) => {
|
|
const target = new URL(url)
|
|
if (target.href === `${resourceOrigin}/.well-known/oauth-protected-resource/mcp`) {
|
|
return Response.json({
|
|
resource: `${resourceOrigin}/mcp`,
|
|
authorization_servers: [`${authorizationOrigin}/`],
|
|
scopes_supported: ["https://www.googleapis.test/auth/bigquery"],
|
|
bearer_methods_supported: ["header"],
|
|
})
|
|
}
|
|
if (target.href === `${authorizationOrigin}/.well-known/oauth-authorization-server`) {
|
|
return Response.json({
|
|
issuer: authorizationOrigin,
|
|
authorization_endpoint: `${authorizationOrigin}/o/oauth2/v2/auth`,
|
|
token_endpoint: `${authorizationOrigin}/token`,
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
|
code_challenge_methods_supported: ["plain", "S256"],
|
|
})
|
|
}
|
|
if (target.href === `${resourceOrigin}/mcp`) {
|
|
const body = typeof init?.body === "string" ? init.body : ""
|
|
if (!body) return new Response(null, { status: 202 })
|
|
const request = rpcRequestSchema.parse(JSON.parse(body))
|
|
mcpMethods.push(request.method)
|
|
if (request.method === "notifications/initialized") return new Response(null, { status: 202 })
|
|
if (request.method === "initialize") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
result: {
|
|
protocolVersion: "2025-06-18",
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "bigquery-style-test", version: "1.0.0" },
|
|
},
|
|
})
|
|
}
|
|
if (request.method === "tools/list") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
result: { tools: [{ name: "execute_sql", inputSchema: { type: "object" } }] },
|
|
})
|
|
}
|
|
}
|
|
return new Response(null, { status: 404 })
|
|
}
|
|
const persistence = new MemoryOAuthPersistence()
|
|
persistence.seedRegistration({
|
|
client_id: "pre-registered-google-client",
|
|
client_secret: "test-client-secret",
|
|
token_endpoint_auth_method: "client_secret_post",
|
|
})
|
|
const client = createEnterpriseMcpClient({ fetch })
|
|
const connection: EnterpriseMcpConnection = {
|
|
id: "oauth-public-bigquery-style",
|
|
serverUrl: `${resourceOrigin}/mcp`,
|
|
authorization: { type: "oauth", persistence },
|
|
}
|
|
|
|
const started = await client.connect({
|
|
connection,
|
|
redirectUri: "https://den.example.test/v1/mcp-connections/oauth/callback",
|
|
authorizationId: "signed-bigquery-state",
|
|
})
|
|
|
|
assert.equal(started.status, "needs_auth")
|
|
if (started.status !== "needs_auth") throw new Error("Expected OAuth authorization to be required.")
|
|
const authorizeUrl = new URL(started.authorizeUrl)
|
|
assert.equal(authorizeUrl.origin, authorizationOrigin)
|
|
assert.equal(authorizeUrl.searchParams.get("client_id"), "pre-registered-google-client")
|
|
assert.equal(authorizeUrl.searchParams.get("scope"), "https://www.googleapis.test/auth/bigquery")
|
|
assert.equal(authorizeUrl.searchParams.get("state"), "signed-bigquery-state")
|
|
assert.ok(mcpMethods.includes("initialize"))
|
|
assert.ok(mcpMethods.includes("tools/list"))
|
|
assert.equal(persistence.credential, undefined)
|
|
})
|
|
|
|
it("refreshes an expired enterprise OAuth credential and persists the replacement", async () => {
|
|
const server = await startOAuthMcpServer()
|
|
try {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
persistence.seedRegistration({ client_id: "enterprise-test-client" })
|
|
persistence.seedCredential({
|
|
access_token: "expired-access-token",
|
|
refresh_token: "enterprise-refresh-token",
|
|
token_type: "Bearer",
|
|
}, Date.now() - 1_000)
|
|
const events: EnterpriseMcpDiagnosticEvent[] = []
|
|
const client = createEnterpriseMcpClient({ fetch, diagnosticSink: (event) => events.push(event) })
|
|
const connection: EnterpriseMcpConnection = {
|
|
id: "oauth-refresh-connection",
|
|
serverUrl: `${server.origin}/mcp`,
|
|
authorization: { type: "oauth", persistence },
|
|
}
|
|
|
|
assert.deepEqual(await client.connect({
|
|
connection,
|
|
redirectUri: "https://den.example.test/oauth-refresh-callback",
|
|
authorizationId: "signed-refresh-state",
|
|
}), { status: "connected" })
|
|
assert.equal(persistence.credential?.tokens.access_token, "enterprise-access-token")
|
|
assert.equal(persistence.credential?.tokens.refresh_token, "enterprise-refresh-token")
|
|
assert.ok(events.some((event) => event.requestPhase === "oauth-token-refresh"))
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
it("preserves a credential when tool execution gets a 401 without invalid_token", async () => {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
persistence.seedRegistration({ client_id: "registered-client" })
|
|
persistence.seedCredential({ access_token: "rejected-access-token", token_type: "Bearer" })
|
|
const client = createEnterpriseMcpClient({
|
|
fetch: runtimeRejectingMcpFetch({ rejectMethod: "tools/call", status: 401 }),
|
|
})
|
|
|
|
await assert.rejects(client.callTool({
|
|
connection: oauthRuntimeConnection("runtime-plain-401", persistence),
|
|
redirectUri: "https://den.example.test/oauth/callback",
|
|
toolName: "oauth-tool",
|
|
arguments: {},
|
|
}))
|
|
assert.equal(persistence.credential?.tokens.access_token, "rejected-access-token")
|
|
assert.equal(persistence.invalidationCount, 0)
|
|
})
|
|
|
|
it("invalidates a credential and emits diagnostics when tool execution gets invalid_token", async () => {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
persistence.seedRegistration({ client_id: "registered-client" })
|
|
persistence.seedCredential({ access_token: "rejected-access-token", token_type: "Bearer" })
|
|
const events: EnterpriseMcpDiagnosticEvent[] = []
|
|
const client = createEnterpriseMcpClient({
|
|
fetch: runtimeRejectingMcpFetch({
|
|
rejectMethod: "tools/call",
|
|
status: 401,
|
|
wwwAuthenticate: 'Bearer error="invalid_token"',
|
|
}),
|
|
diagnosticSink: (event) => events.push(event),
|
|
})
|
|
|
|
await assert.rejects(client.callTool({
|
|
connection: oauthRuntimeConnection("runtime-invalid-token", persistence),
|
|
redirectUri: "https://den.example.test/oauth/callback",
|
|
toolName: "oauth-tool",
|
|
arguments: {},
|
|
}))
|
|
assert.equal(persistence.credential, undefined)
|
|
assert.equal(persistence.invalidationCount, 1)
|
|
const invalidationEvents = events.filter((event) => event.kind === "credential-invalidation")
|
|
assert.equal(invalidationEvents.length, 1)
|
|
const [event] = invalidationEvents
|
|
if (!event || event.kind !== "credential-invalidation") throw new Error("Expected a credential invalidation event.")
|
|
assert.deepEqual(event, {
|
|
kind: "credential-invalidation",
|
|
connectionId: "runtime-invalid-token",
|
|
operationPhase: "tool-execution",
|
|
requestPhase: "mcp-tool-execution",
|
|
httpStatus: 401,
|
|
invalidToken: true,
|
|
})
|
|
})
|
|
|
|
it("preserves a credential when tool discovery gets invalid_token", async () => {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
persistence.seedRegistration({ client_id: "registered-client" })
|
|
persistence.seedCredential({ access_token: "rejected-access-token", token_type: "Bearer" })
|
|
const client = createEnterpriseMcpClient({
|
|
fetch: runtimeRejectingMcpFetch({
|
|
rejectMethod: "tools/list",
|
|
status: 401,
|
|
wwwAuthenticate: 'Bearer error="invalid_token"',
|
|
}),
|
|
})
|
|
|
|
await assert.rejects(client.listTools({
|
|
connection: oauthRuntimeConnection("discovery-invalid-token", persistence),
|
|
redirectUri: "https://den.example.test/oauth/callback",
|
|
}))
|
|
assert.equal(persistence.credential?.tokens.access_token, "rejected-access-token")
|
|
assert.equal(persistence.invalidationCount, 0)
|
|
})
|
|
|
|
it("preserves a credential when a provider returns a plain tool-policy 403", async () => {
|
|
const server = await startOAuthMcpServer({ rejectAuthenticatedToolsList: true })
|
|
try {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
persistence.seedRegistration({ client_id: "enterprise-test-client" })
|
|
persistence.seedCredential({
|
|
access_token: "enterprise-access-token",
|
|
refresh_token: "enterprise-refresh-token",
|
|
token_type: "Bearer",
|
|
})
|
|
const client = createEnterpriseMcpClient({ fetch })
|
|
const connection: EnterpriseMcpConnection = {
|
|
id: "provider-acl-denial",
|
|
serverUrl: `${server.origin}/mcp`,
|
|
authorization: { type: "oauth", persistence },
|
|
}
|
|
|
|
await assert.rejects(client.listTools({
|
|
connection,
|
|
redirectUri: "https://den.example.test/oauth/callback",
|
|
}))
|
|
assert.equal(persistence.credential?.tokens.access_token, "enterprise-access-token")
|
|
assert.equal(persistence.invalidationCount, 0)
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
it("still invalidates a credential when tool execution gets a 403 bearer challenge", async () => {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
persistence.seedRegistration({ client_id: "registered-client" })
|
|
persistence.seedCredential({ access_token: "rejected-access-token", token_type: "Bearer" })
|
|
const client = createEnterpriseMcpClient({
|
|
fetch: runtimeRejectingMcpFetch({
|
|
rejectMethod: "tools/call",
|
|
status: 403,
|
|
wwwAuthenticate: "Bearer realm=\"provider\"",
|
|
}),
|
|
})
|
|
|
|
await assert.rejects(client.callTool({
|
|
connection: oauthRuntimeConnection("runtime-bearer-403", persistence),
|
|
redirectUri: "https://den.example.test/oauth/callback",
|
|
toolName: "oauth-tool",
|
|
arguments: {},
|
|
}))
|
|
assert.equal(persistence.credential, undefined)
|
|
assert.equal(persistence.invalidationCount, 1)
|
|
})
|
|
|
|
it("rejects a stale concurrent refresh response instead of overwriting newer credentials", async () => {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
persistence.seedCredential({
|
|
access_token: "original-access-token",
|
|
refresh_token: "original-refresh-token",
|
|
token_type: "Bearer",
|
|
}, Date.now() + 60_000)
|
|
const first = oauthProvider({ persistence, flow: { kind: "runtime" } })
|
|
const second = oauthProvider({ persistence, flow: { kind: "runtime" } })
|
|
await first.tokens()
|
|
await second.tokens()
|
|
|
|
await first.saveTokens({
|
|
access_token: "first-refreshed-access-token",
|
|
refresh_token: "first-rotated-refresh-token",
|
|
token_type: "Bearer",
|
|
})
|
|
await assert.rejects(second.saveTokens({
|
|
access_token: "stale-refreshed-access-token",
|
|
refresh_token: "stale-rotated-refresh-token",
|
|
token_type: "Bearer",
|
|
}), (error: unknown) => error instanceof EnterpriseMcpOAuthContractError
|
|
&& error.code === "MCP_OAUTH_CREDENTIAL_CHANGED")
|
|
assert.equal(persistence.credential?.tokens.access_token, "first-refreshed-access-token")
|
|
assert.equal(persistence.credential?.tokens.refresh_token, "first-rotated-refresh-token")
|
|
})
|
|
|
|
it("invalidates exchanged tokens when callback validation cannot initialize MCP", async () => {
|
|
const server = await startOAuthMcpServer({ rejectAuthenticatedMcp: true })
|
|
try {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
const client = createEnterpriseMcpClient({ fetch, operationTimeoutMs: 5_000 })
|
|
const connection: EnterpriseMcpConnection = {
|
|
id: "oauth-validation-failure",
|
|
serverUrl: `${server.origin}/mcp`,
|
|
authorization: { type: "oauth", persistence },
|
|
}
|
|
const redirectUri = "https://den.example.test/oauth-validation-failure"
|
|
const started = await client.connect({ connection, redirectUri, authorizationId: "signed-state" })
|
|
assert.equal(started.status, "needs_auth")
|
|
|
|
await assert.rejects(client.completeAuthorization({
|
|
connection,
|
|
redirectUri,
|
|
code: "approved-code",
|
|
authorizationId: "signed-state",
|
|
}))
|
|
assert.equal(persistence.credential, undefined)
|
|
assert.equal(persistence.invalidationCount, 1)
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
it("reports credential invalidation failures alongside callback validation failures", async () => {
|
|
const server = await startOAuthMcpServer({ rejectAuthenticatedMcp: true })
|
|
try {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
Object.defineProperty(persistence.credentials, "invalidate", {
|
|
value: async () => { throw new Error("credential store unavailable") },
|
|
})
|
|
const client = createEnterpriseMcpClient({ fetch, operationTimeoutMs: 5_000 })
|
|
const connection: EnterpriseMcpConnection = {
|
|
id: "oauth-cleanup-failure",
|
|
serverUrl: `${server.origin}/mcp`,
|
|
authorization: { type: "oauth", persistence },
|
|
}
|
|
const redirectUri = "https://den.example.test/oauth-cleanup-failure"
|
|
const started = await client.connect({ connection, redirectUri, authorizationId: "signed-state" })
|
|
assert.equal(started.status, "needs_auth")
|
|
|
|
await assert.rejects(client.completeAuthorization({
|
|
connection,
|
|
redirectUri,
|
|
code: "approved-code",
|
|
authorizationId: "signed-state",
|
|
}), (error: unknown) => error instanceof EnterpriseMcpClientError
|
|
&& error.cause instanceof AggregateError
|
|
&& error.cause.errors.some((cause) => cause instanceof Error && cause.message === "credential store unavailable"))
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
it("invalidates exchanged tokens when callback initialization succeeds but tool discovery fails", async () => {
|
|
const server = await startOAuthMcpServer({ rejectAuthenticatedToolsList: true })
|
|
try {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
const client = createEnterpriseMcpClient({ fetch, operationTimeoutMs: 5_000 })
|
|
const connection: EnterpriseMcpConnection = {
|
|
id: "oauth-tool-validation-failure",
|
|
serverUrl: `${server.origin}/mcp`,
|
|
authorization: { type: "oauth", persistence },
|
|
}
|
|
const redirectUri = "https://den.example.test/oauth-tool-validation-failure"
|
|
const started = await client.connect({ connection, redirectUri, authorizationId: "signed-tool-state" })
|
|
assert.equal(started.status, "needs_auth")
|
|
|
|
await assert.rejects(client.completeAuthorization({
|
|
connection,
|
|
redirectUri,
|
|
code: "approved-code",
|
|
authorizationId: "signed-tool-state",
|
|
}))
|
|
assert.equal(persistence.credential, undefined)
|
|
assert.equal(persistence.invalidationCount, 1)
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
it("requires an explicit signed authorization id before OAuth performs network or persistence work", async () => {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
let fetchCount = 0
|
|
const client = createEnterpriseMcpClient({
|
|
fetch: async () => {
|
|
fetchCount += 1
|
|
return new Response(null, { status: 500 })
|
|
},
|
|
})
|
|
await assert.rejects(
|
|
client.connect({
|
|
connection: {
|
|
id: "oauth-missing-state",
|
|
serverUrl: "https://mcp.example.test/mcp",
|
|
authorization: { type: "oauth", persistence },
|
|
},
|
|
redirectUri: "https://den.example.test/callback",
|
|
}),
|
|
(error: unknown) => error instanceof EnterpriseMcpClientError
|
|
&& error.code === "MCP_CONFIGURATION_FAILED",
|
|
)
|
|
assert.equal(fetchCount, 0)
|
|
assert.equal(persistence.authorizationRecords.size, 0)
|
|
})
|
|
|
|
it("keeps concurrent PKCE transactions isolated by signed state", async () => {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
persistence.seedRegistration({ client_id: "registered-client" })
|
|
const first = oauthProvider({
|
|
persistence,
|
|
flow: { kind: "connect", authorizationId: "signed-state-a" },
|
|
})
|
|
const second = oauthProvider({
|
|
persistence,
|
|
flow: { kind: "connect", authorizationId: "signed-state-b" },
|
|
})
|
|
await first.clientInformation()
|
|
await second.clientInformation()
|
|
await first.saveCodeVerifier("a".repeat(43))
|
|
await second.saveCodeVerifier("b".repeat(43))
|
|
assert.equal(persistence.authorizationRecords.size, 2)
|
|
|
|
const firstCallback = oauthProvider({
|
|
persistence,
|
|
flow: { kind: "callback", authorizationId: "signed-state-a" },
|
|
})
|
|
const secondCallback = oauthProvider({
|
|
persistence,
|
|
flow: { kind: "callback", authorizationId: "signed-state-b" },
|
|
})
|
|
await firstCallback.clientInformation()
|
|
await secondCallback.clientInformation()
|
|
assert.equal(await firstCallback.codeVerifier(), "a".repeat(43))
|
|
assert.equal(await secondCallback.codeVerifier(), "b".repeat(43))
|
|
})
|
|
|
|
it("rejects an expired authorization transaction with a stable source code", async () => {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
persistence.seedRegistration({ client_id: "registered-client" })
|
|
const base = Date.now()
|
|
let now = base
|
|
const start = oauthProvider({
|
|
persistence,
|
|
flow: { kind: "connect", authorizationId: "signed-expiring-state" },
|
|
now: () => now,
|
|
authorizationTransactionTtlMs: 100,
|
|
})
|
|
await start.clientInformation()
|
|
await start.saveCodeVerifier("v".repeat(43))
|
|
now = base + 101
|
|
const callback = oauthProvider({
|
|
persistence,
|
|
flow: { kind: "callback", authorizationId: "signed-expiring-state" },
|
|
now: () => now,
|
|
})
|
|
await callback.clientInformation()
|
|
await assert.rejects(
|
|
callback.codeVerifier(),
|
|
(error: unknown) => error instanceof EnterpriseMcpOAuthContractError
|
|
&& error.code === "MCP_OAUTH_AUTHORIZATION_EXPIRED",
|
|
)
|
|
assert.equal(persistence.authorizationRecords.size, 0)
|
|
})
|
|
|
|
it("rejects callbacks when the OAuth client changed after authorization started", async () => {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
persistence.seedRegistration({ client_id: "client-a" })
|
|
const start = oauthProvider({
|
|
persistence,
|
|
flow: { kind: "connect", authorizationId: "signed-client-revision" },
|
|
})
|
|
await start.clientInformation()
|
|
await start.saveCodeVerifier("v".repeat(43))
|
|
persistence.seedRegistration({ client_id: "client-b" })
|
|
const callback = oauthProvider({
|
|
persistence,
|
|
flow: { kind: "callback", authorizationId: "signed-client-revision" },
|
|
})
|
|
await callback.clientInformation()
|
|
await assert.rejects(
|
|
callback.codeVerifier(),
|
|
(error: unknown) => error instanceof EnterpriseMcpOAuthContractError
|
|
&& error.code === "MCP_OAUTH_AUTHORIZATION_CLIENT_CHANGED",
|
|
)
|
|
})
|
|
|
|
it("invalidates an expired access token when no refresh token exists", async () => {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
persistence.seedCredential({ access_token: "expired", token_type: "Bearer" }, 999)
|
|
const provider = oauthProvider({
|
|
persistence,
|
|
flow: { kind: "runtime" },
|
|
now: () => 1_000,
|
|
})
|
|
await assert.rejects(
|
|
provider.tokens(),
|
|
(error: unknown) => error instanceof EnterpriseMcpOAuthContractError
|
|
&& error.code === "MCP_OAUTH_CREDENTIAL_EXPIRED",
|
|
)
|
|
assert.equal(persistence.credential, undefined)
|
|
})
|
|
|
|
it("fails a losing concurrent dynamic registration instead of using the wrong client", async () => {
|
|
const persistence = new MemoryOAuthPersistence()
|
|
const winner = oauthProvider({ persistence, flow: { kind: "connect", authorizationId: "winner-state" } })
|
|
const loser = oauthProvider({ persistence, flow: { kind: "connect", authorizationId: "loser-state" } })
|
|
await winner.saveClientInformation({ client_id: "winner-client" })
|
|
await assert.rejects(
|
|
loser.saveClientInformation({ client_id: "loser-client" }),
|
|
(error: unknown) => error instanceof EnterpriseMcpOAuthContractError
|
|
&& error.code === "MCP_OAUTH_AUTHORIZATION_CLIENT_CHANGED",
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("enterprise MCP catalog contract", () => {
|
|
it("collects resources without changing standard UI metadata", async () => {
|
|
const resources = await collectEnterpriseMcpResources({
|
|
requestOptions: {},
|
|
listPage: async (cursor) => cursor
|
|
? { resources: [{ name: "Second", uri: "ui://second", _meta: { ui: { prefersBorder: false } } }] }
|
|
: {
|
|
resources: [{ name: "First", uri: "ui://first", _meta: { ui: { prefersBorder: true } } }],
|
|
nextCursor: "page-2",
|
|
},
|
|
})
|
|
assert.deepEqual(resources.map((resource) => [resource.uri, resource._meta]), [
|
|
["ui://first", { ui: { prefersBorder: true } }],
|
|
["ui://second", { ui: { prefersBorder: false } }],
|
|
])
|
|
})
|
|
|
|
it("rejects duplicate resource URIs and oversized resource results", async () => {
|
|
await assert.rejects(collectEnterpriseMcpResources({
|
|
requestOptions: {},
|
|
listPage: async (cursor) => cursor
|
|
? { resources: [{ name: "Again", uri: "ui://duplicate" }] }
|
|
: { resources: [{ name: "First", uri: "ui://duplicate" }], nextCursor: "again" },
|
|
}), (error: unknown) => error instanceof EnterpriseMcpCatalogError
|
|
&& error.code === "MCP_CATALOG_DUPLICATE_RESOURCE")
|
|
assert.throws(
|
|
() => assertEnterpriseMcpResourceResult({ contents: [{ text: "x".repeat(2 * 1024 * 1024) }] }),
|
|
(error: unknown) => error instanceof EnterpriseMcpCatalogError
|
|
&& error.code === "MCP_RESOURCE_RESULT_LIMIT",
|
|
)
|
|
})
|
|
|
|
it("collects a bounded paginated tool catalog", async () => {
|
|
const tools = await collectEnterpriseMcpTools({
|
|
requestOptions: {},
|
|
listPage: async (cursor) => cursor
|
|
? {
|
|
tools: [{ name: "second-tool", inputSchema: { type: "object" } }],
|
|
}
|
|
: {
|
|
tools: [{ name: "first-tool", inputSchema: { type: "object" } }],
|
|
nextCursor: "page-2",
|
|
},
|
|
})
|
|
assert.deepEqual(tools.map((tool) => tool.name), ["first-tool", "second-tool"])
|
|
})
|
|
|
|
it("rejects duplicate tools across catalog pages", async () => {
|
|
await assert.rejects(
|
|
collectEnterpriseMcpTools({
|
|
requestOptions: {},
|
|
listPage: async (cursor) => cursor
|
|
? { tools: [{ name: "duplicate", inputSchema: { type: "object" } }] }
|
|
: {
|
|
tools: [{ name: "duplicate", inputSchema: { type: "object" } }],
|
|
nextCursor: "page-2",
|
|
},
|
|
}),
|
|
(error: unknown) => {
|
|
assert.ok(error instanceof EnterpriseMcpCatalogError)
|
|
assert.equal(error.code, "MCP_CATALOG_DUPLICATE_TOOL")
|
|
return true
|
|
},
|
|
)
|
|
})
|
|
|
|
it("rejects a repeated pagination cursor", async () => {
|
|
await assert.rejects(
|
|
collectEnterpriseMcpTools({
|
|
requestOptions: {},
|
|
listPage: async () => ({ tools: [], nextCursor: "repeated-cursor" }),
|
|
}),
|
|
(error: unknown) => error instanceof EnterpriseMcpCatalogError
|
|
&& error.code === "MCP_CATALOG_CURSOR_LOOP",
|
|
)
|
|
})
|
|
|
|
it("enforces the absolute catalog page limit", async () => {
|
|
let page = 0
|
|
await assert.rejects(
|
|
collectEnterpriseMcpTools({
|
|
requestOptions: {},
|
|
listPage: async () => {
|
|
page += 1
|
|
return { tools: [], nextCursor: `page-${page}` }
|
|
},
|
|
}),
|
|
(error: unknown) => error instanceof EnterpriseMcpCatalogError
|
|
&& error.code === "MCP_CATALOG_PAGE_LIMIT",
|
|
)
|
|
assert.equal(page, 20)
|
|
})
|
|
|
|
it("rejects oversized tool names and deeply nested schemas", async () => {
|
|
await assert.rejects(
|
|
collectEnterpriseMcpTools({
|
|
requestOptions: {},
|
|
listPage: async () => ({
|
|
tools: [{ name: "x".repeat(513), inputSchema: { type: "object" } }],
|
|
}),
|
|
}),
|
|
(error: unknown) => error instanceof EnterpriseMcpCatalogError
|
|
&& error.code === "MCP_CATALOG_TOOL_NAME_LIMIT",
|
|
)
|
|
|
|
let nested: Record<string, unknown> = { type: "string" }
|
|
for (let depth = 0; depth < 70; depth += 1) nested = { nested }
|
|
await assert.rejects(
|
|
collectEnterpriseMcpTools({
|
|
requestOptions: {},
|
|
listPage: async () => ({
|
|
tools: [{
|
|
name: "deep-schema",
|
|
inputSchema: { type: "object", properties: { value: nested } },
|
|
}],
|
|
}),
|
|
}),
|
|
(error: unknown) => error instanceof EnterpriseMcpCatalogError
|
|
&& error.code === "MCP_CATALOG_SCHEMA_DEPTH_LIMIT",
|
|
)
|
|
})
|
|
})
|