330 lines
14 KiB
TypeScript
330 lines
14 KiB
TypeScript
import assert from "node:assert/strict"
|
|
import test from "node:test"
|
|
import { z } from "zod"
|
|
import {
|
|
createDefaultScenario,
|
|
createEnterpriseMcpMockServer,
|
|
createFaultScenario,
|
|
getProviderProfile,
|
|
scenarioSchema,
|
|
toolInputSchemaSchema,
|
|
} from "../src/index.js"
|
|
import { authorizeManual, callRpc, initializeSession, rpcHeaders, sessionHeaders } from "./wire-client.js"
|
|
|
|
const oauthClientSecret = "synthetic-test-client-secret-32-bytes"
|
|
const errorEnvelopeSchema = z.object({ error: z.object({ code: z.number(), message: z.string() }) })
|
|
|
|
test("Origin is rejected before credentials and session binding prevents cross-token use or deletion", async () => {
|
|
const scenario = createDefaultScenario("servicenow-inbound-quickstart")
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret } })
|
|
await server.start()
|
|
try {
|
|
const hostileOrigin = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
origin: "https://attacker.example",
|
|
accept: "application/json, text/event-stream",
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
|
|
})
|
|
assert.equal(hostileOrigin.status, 403)
|
|
assert.equal(hostileOrigin.headers.get("www-authenticate"), null)
|
|
|
|
const firstToken = await authorizeManual(server.baseUrl, scenario, oauthClientSecret)
|
|
const secondToken = await authorizeManual(server.baseUrl, scenario, oauthClientSecret)
|
|
const firstSession = await initializeSession(server.baseUrl, scenario, firstToken)
|
|
const profile = getProviderProfile(scenario.profileId)
|
|
const hijackHeaders = sessionHeaders(
|
|
server.baseUrl,
|
|
secondToken.accessToken,
|
|
firstSession.sessionId,
|
|
firstSession.protocolVersion,
|
|
)
|
|
const hijack = await fetch(new URL(profile.endpointPath, server.baseUrl), {
|
|
method: "POST",
|
|
headers: hijackHeaders,
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }),
|
|
})
|
|
assert.equal(hijack.status, 403)
|
|
|
|
const foreignDelete = await fetch(new URL(profile.endpointPath, server.baseUrl), {
|
|
method: "DELETE",
|
|
headers: hijackHeaders,
|
|
})
|
|
assert.equal(foreignDelete.status, 403)
|
|
assert.equal(server.snapshot().counts.sessions, 1)
|
|
|
|
const ownDelete = await fetch(new URL(profile.endpointPath, server.baseUrl), {
|
|
method: "DELETE",
|
|
headers: sessionHeaders(
|
|
server.baseUrl,
|
|
firstToken.accessToken,
|
|
firstSession.sessionId,
|
|
firstSession.protocolVersion,
|
|
),
|
|
})
|
|
assert.equal(ownDelete.status, 204)
|
|
assert.equal(server.snapshot().counts.sessions, 0)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("transport, JSON-RPC, protocol, and session validation stay precisely shaped", async () => {
|
|
const scenario = createDefaultScenario("servicenow-inbound-quickstart")
|
|
const profile = getProviderProfile(scenario.profileId)
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret } })
|
|
await server.start()
|
|
try {
|
|
const token = await authorizeManual(server.baseUrl, scenario, oauthClientSecret)
|
|
const plainText = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: { ...rpcHeaders(server.baseUrl, token.accessToken), "content-type": "text/plain" },
|
|
body: "not json",
|
|
})
|
|
assert.equal(plainText.status, 415)
|
|
|
|
const jsonp = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: { ...rpcHeaders(server.baseUrl, token.accessToken), "content-type": "application/jsonp" },
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
|
|
})
|
|
assert.equal(jsonp.status, 415)
|
|
|
|
const malformed = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: rpcHeaders(server.baseUrl, token.accessToken),
|
|
body: "{",
|
|
})
|
|
assert.equal(malformed.status, 200)
|
|
const malformedValue: unknown = JSON.parse(await malformed.text())
|
|
assert.equal(errorEnvelopeSchema.parse(malformedValue).error.code, -32700)
|
|
|
|
const invalidRequest = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: rpcHeaders(server.baseUrl, token.accessToken),
|
|
body: JSON.stringify({ hello: "world" }),
|
|
})
|
|
const invalidValue: unknown = JSON.parse(await invalidRequest.text())
|
|
assert.equal(errorEnvelopeSchema.parse(invalidValue).error.code, -32600)
|
|
|
|
const missingAccept = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `Bearer ${token.accessToken}`,
|
|
accept: "application/json",
|
|
"content-type": "application/json",
|
|
origin: new URL(server.baseUrl).origin,
|
|
},
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
|
|
})
|
|
assert.equal(missingAccept.status, 406)
|
|
|
|
const nearMatchAccept = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `Bearer ${token.accessToken}`,
|
|
accept: "application/jsonp, text/event-streaming",
|
|
"content-type": "application/json",
|
|
origin: new URL(server.baseUrl).origin,
|
|
},
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
|
|
})
|
|
assert.equal(nearMatchAccept.status, 406)
|
|
|
|
const explicitlyRejectedRepresentations = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `Bearer ${token.accessToken}`,
|
|
accept: "application/json;q=0, text/event-stream;q=0",
|
|
"content-type": "application/json",
|
|
origin: new URL(server.baseUrl).origin,
|
|
},
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
|
|
})
|
|
assert.equal(explicitlyRejectedRepresentations.status, 406)
|
|
|
|
const lowercaseBearer = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: { ...rpcHeaders(server.baseUrl, token.accessToken), authorization: `bearer ${token.accessToken}` },
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: 19,
|
|
method: "initialize",
|
|
params: { protocolVersion: scenario.protocol.version, capabilities: {}, clientInfo: { name: "wire-test", version: "1" } },
|
|
}),
|
|
})
|
|
assert.equal(lowercaseBearer.status, 200)
|
|
|
|
const missingCapabilities = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: rpcHeaders(server.baseUrl, token.accessToken),
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: 20,
|
|
method: "initialize",
|
|
params: { protocolVersion: "1900-01-01", clientInfo: { name: "wire-test", version: "1" } },
|
|
}),
|
|
})
|
|
assert.equal(errorEnvelopeSchema.parse(JSON.parse(await missingCapabilities.text())).error.code, -32602)
|
|
|
|
const downgraded = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: rpcHeaders(server.baseUrl, token.accessToken),
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: 21,
|
|
method: "initialize",
|
|
params: {
|
|
protocolVersion: "1900-01-01",
|
|
capabilities: {},
|
|
clientInfo: { name: "wire-test", version: "1" },
|
|
},
|
|
}),
|
|
})
|
|
const downgradedEnvelope = z.object({
|
|
result: z.object({ protocolVersion: z.string() }),
|
|
}).parse(JSON.parse(await downgraded.text()))
|
|
assert.equal(downgradedEnvelope.result.protocolVersion, scenario.protocol.version)
|
|
assert.equal(downgraded.headers.get("mcp-protocol-version"), scenario.protocol.version)
|
|
|
|
const session = await initializeSession(server.baseUrl, scenario, token)
|
|
const missingSession = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: rpcHeaders(server.baseUrl, token.accessToken),
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }),
|
|
})
|
|
assert.equal(missingSession.status, 400)
|
|
|
|
const wrongProtocol = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
...sessionHeaders(server.baseUrl, token.accessToken, session.sessionId, "1900-01-01"),
|
|
},
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/list", params: {} }),
|
|
})
|
|
assert.equal(wrongProtocol.status, 400)
|
|
|
|
const unknown = await callRpc(server.baseUrl, scenario, session, 4, "unknown/method", {})
|
|
assert.equal(unknown.envelope.error?.code, -32601)
|
|
const invalidArguments = await callRpc(server.baseUrl, scenario, session, 5, "tools/call", {
|
|
name: "get_incident",
|
|
arguments: { number: 42, extra: true },
|
|
})
|
|
assert.equal(invalidArguments.envelope.error?.code, -32602)
|
|
|
|
const initializedWithId = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: sessionHeaders(server.baseUrl, session.accessToken, session.sessionId, session.protocolVersion),
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: 6, method: "notifications/initialized" }),
|
|
})
|
|
assert.equal(initializedWithId.status, 200)
|
|
assert.equal(errorEnvelopeSchema.parse(JSON.parse(await initializedWithId.text())).error.code, -32600)
|
|
|
|
const unknownNotification = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: sessionHeaders(server.baseUrl, session.accessToken, session.sessionId, session.protocolVersion),
|
|
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/synthetic-unknown" }),
|
|
})
|
|
assert.equal(unknownNotification.status, 202)
|
|
assert.equal(await unknownNotification.text(), "")
|
|
|
|
const clientResponse = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: sessionHeaders(server.baseUrl, session.accessToken, session.sessionId, session.protocolVersion),
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: 91, result: { accepted: true } }),
|
|
})
|
|
assert.equal(clientResponse.status, 202)
|
|
assert.equal(await clientResponse.text(), "")
|
|
|
|
const badCursor = await callRpc(server.baseUrl, scenario, session, 7, "tools/list", { cursor: "page:1junk" })
|
|
assert.equal(badCursor.envelope.error?.code, -32602)
|
|
|
|
const oversized = "x".repeat(1024 * 1024 + 1)
|
|
const oversizedResponse = await fetch(new URL(profile.endpointPath, server.baseUrl), {
|
|
method: "POST",
|
|
headers: rpcHeaders(server.baseUrl, token.accessToken),
|
|
body: oversized,
|
|
})
|
|
assert.equal(oversizedResponse.status, 413)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("tool results mirror structured content for legacy clients and keep the synthetic profile vendor-neutral", async () => {
|
|
const base = createDefaultScenario("synthetic-enterprise-oauth-mcp")
|
|
const scenario = scenarioSchema.parse({ ...base, oauth: { ...base.oauth, registration: "manual" } })
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret } })
|
|
await server.start()
|
|
try {
|
|
const token = await authorizeManual(server.baseUrl, scenario, oauthClientSecret)
|
|
const session = await initializeSession(server.baseUrl, scenario, token)
|
|
const call = await callRpc(server.baseUrl, scenario, session, 30, "tools/call", {
|
|
name: "search_records",
|
|
arguments: { query: "synthetic" },
|
|
})
|
|
const result = z.object({
|
|
isError: z.literal(false),
|
|
content: z.array(z.object({ type: z.literal("text"), text: z.string() })).min(1),
|
|
structuredContent: z.object({ provider: z.string() }).passthrough(),
|
|
}).parse(call.envelope.result)
|
|
assert.equal(result.structuredContent.provider, "synthetic")
|
|
assert.deepEqual(JSON.parse(result.content[0]?.text ?? ""), result.structuredContent)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("invalid tool-schema fault is genuinely invalid JSON Schema on the wire", async () => {
|
|
const scenario = createFaultScenario("servicenow-inbound-quickstart", "mcp-invalid-tool-schema")
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret } })
|
|
await server.start()
|
|
try {
|
|
const token = await authorizeManual(server.baseUrl, scenario, oauthClientSecret)
|
|
const session = await initializeSession(server.baseUrl, scenario, token)
|
|
const list = await callRpc(server.baseUrl, scenario, session, 29, "tools/list", {})
|
|
const result = z.object({
|
|
tools: z.array(z.object({ inputSchema: z.unknown() }).passthrough()).min(1),
|
|
}).parse(list.envelope.result)
|
|
const inputSchema = z.object({
|
|
type: z.literal("object"),
|
|
required: z.string(),
|
|
}).passthrough().parse(result.tools[0]?.inputSchema)
|
|
|
|
assert.equal(inputSchema.required, "missing")
|
|
assert.equal(toolInputSchemaSchema.safeParse(inputSchema).success, false)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("provider tool faults expose top-level Den-compatible evidence and a detailed nested error", async () => {
|
|
const scenario = createFaultScenario("servicenow-inbound-quickstart", "provider-authorization-denied")
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret } })
|
|
await server.start()
|
|
try {
|
|
const token = await authorizeManual(server.baseUrl, scenario, oauthClientSecret)
|
|
const session = await initializeSession(server.baseUrl, scenario, token)
|
|
const call = await callRpc(server.baseUrl, scenario, session, 31, "tools/call", {
|
|
name: "get_incident",
|
|
arguments: { number: "INC0000001" },
|
|
})
|
|
const result = z.object({
|
|
isError: z.literal(true),
|
|
content: z.array(z.object({ type: z.literal("text"), text: z.string() })).min(1),
|
|
structuredContent: z.object({
|
|
providerStatus: z.literal(403),
|
|
category: z.literal("provider_acl"),
|
|
requestId: z.string(),
|
|
error: z.object({ code: z.literal("PROVIDER_ACL_DENIED"), providerRequestId: z.string() }).passthrough(),
|
|
}).passthrough(),
|
|
}).parse(call.envelope.result)
|
|
assert.equal(result.structuredContent.requestId, result.structuredContent.error.providerRequestId)
|
|
assert.deepEqual(JSON.parse(result.content[0]?.text ?? ""), result.structuredContent)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|