239 lines
12 KiB
TypeScript
239 lines
12 KiB
TypeScript
import assert from "node:assert/strict"
|
|
import test from "node:test"
|
|
import {
|
|
createDefaultScenario,
|
|
createFaultScenario,
|
|
getProviderProfile,
|
|
isSafeOAuthRedirectUri,
|
|
listFaultDefinitions,
|
|
listProviderProfiles,
|
|
scenarioSchema,
|
|
toolInputSchemaSchema,
|
|
validateToolArguments,
|
|
type EnterpriseMcpScenario,
|
|
type FaultDefinition,
|
|
type MockTool,
|
|
type ProviderProfile,
|
|
} from "../src/index.js"
|
|
|
|
function compileTimeScenarioReadonly(scenario: EnterpriseMcpScenario): void {
|
|
// @ts-expect-error Runtime-frozen scenario fields must also be readonly in the public declaration.
|
|
scenario.revision = 2
|
|
// @ts-expect-error Nested scenario arrays must also be readonly in the public declaration.
|
|
scenario.oauth.authorizationScopes.push("unexpected")
|
|
}
|
|
void compileTimeScenarioReadonly
|
|
|
|
function compileTimeCatalogReadonly(profile: ProviderProfile, fault: FaultDefinition): void {
|
|
// @ts-expect-error Runtime-frozen provider profiles must also be readonly in the public declaration.
|
|
profile.displayName = "changed"
|
|
// @ts-expect-error Nested profile tools must also be readonly in the public declaration.
|
|
profile.tools.push(profile.tools[0])
|
|
// @ts-expect-error Runtime-frozen fault definitions must also be readonly in the public declaration.
|
|
fault.category = "changed"
|
|
}
|
|
void compileTimeCatalogReadonly
|
|
|
|
test("provider profiles are declarative, unique, and provenance-labelled", () => {
|
|
const profiles = listProviderProfiles()
|
|
assert.equal(Object.isFrozen(profiles), true)
|
|
assert.equal(profiles.length, 6)
|
|
assert.equal(new Set(profiles.map((profile) => profile.id)).size, profiles.length)
|
|
for (const profile of profiles) {
|
|
assert.equal(getProviderProfile(profile.id), profile)
|
|
assert.equal(Object.isFrozen(profile), true)
|
|
assert.equal(Object.isFrozen(profile.provenance), true)
|
|
assert.equal(Object.isFrozen(profile.tools), true)
|
|
assert.equal(Object.isFrozen(profile.tools[0]?.inputSchema), true)
|
|
assert.equal(profile.fixtureVersion, "2026-07-12.1")
|
|
assert.ok(profile.provenance.documentationUrls.length > 0)
|
|
assert.ok(profile.provenance.limitations.length > 0)
|
|
assert.ok(profile.oauth.requiredResourceScopes.every((scope) => profile.oauth.authorizationScopes.includes(scope)))
|
|
assert.equal(new Set(profile.tools.map((tool) => tool.name)).size, profile.tools.length)
|
|
assert.equal(profile.provenance.aspectFidelity.providerResults === "provider-documented", false)
|
|
if (profile.provider !== "synthetic") assert.equal(profile.provenance.aspectFidelity.authorization, "synthetic")
|
|
}
|
|
|
|
const serviceNow = profiles.find((profile) => profile.id === "servicenow-inbound-quickstart")
|
|
assert.ok(serviceNow)
|
|
assert.deepEqual(serviceNow.oauth.registrationModes, ["manual"])
|
|
assert.deepEqual(serviceNow.oauth.authorizationScopes, ["mcp_server"])
|
|
assert.equal(serviceNow.oauth.authorizationPath, "/oauth_auth.do")
|
|
assert.equal(serviceNow.oauth.tokenPath, "/oauth_token.do")
|
|
assert.equal(serviceNow.oauth.revocationPath, "/oauth_revoke.do")
|
|
assert.equal(serviceNow.provenance.aspectFidelity.catalog, "synthetic")
|
|
|
|
const workIq = profiles.find((profile) => profile.id === "microsoft-work-iq")
|
|
assert.ok(workIq)
|
|
assert.equal(workIq.canonicalEndpoint, "https://workiq.svc.cloud.microsoft/mcp")
|
|
assert.ok(workIq.oauth.authorizationScopes.includes("api://workiq.svc.cloud.microsoft/WorkIQAgent.Ask"))
|
|
assert.ok(!workIq.oauth.requiredResourceScopes.includes("offline_access"))
|
|
assert.equal(workIq.provenance.aspectFidelity.toolSchemas, "synthetic")
|
|
assert.equal(workIq.provenance.aspectFidelity.providerResults, "synthetic")
|
|
|
|
const documentedWorkIqInputs: Readonly<Record<string, readonly string[]>> = {
|
|
fetch: ["agentId", "entityUrls"],
|
|
create_entity: ["agentId", "approved", "idempotency_key", "jsonBody", "parentUrl"],
|
|
update_entity: ["agentId", "approved", "entityUrl", "idempotency_key", "jsonBody"],
|
|
delete_entity: ["agentId", "approved", "entityUrl", "idempotency_key"],
|
|
do_action: ["actionUrl", "agentId", "approved", "idempotency_key", "jsonBody"],
|
|
call_function: ["agentId", "functionUrl"],
|
|
ask: ["agentId", "conversationId", "fileUrls", "question", "timeZone"],
|
|
list_agents: [],
|
|
get_schema: ["agentId", "backend", "format", "operationIds", "operationType", "path"],
|
|
search_paths: ["agentId", "backend", "filter"],
|
|
}
|
|
for (const [toolName, propertyNames] of Object.entries(documentedWorkIqInputs)) {
|
|
const matchingTool: MockTool | undefined = workIq.tools.find((candidate) => candidate.name === toolName)
|
|
assert.ok(matchingTool, `Missing Work IQ tool ${toolName}`)
|
|
assert.deepEqual(Object.keys(matchingTool.inputSchema.properties).sort(), [...propertyNames].sort())
|
|
}
|
|
assert.deepEqual(workIq.tools.find((tool) => tool.name === "fetch")?.inputSchema.required, ["entityUrls"])
|
|
assert.deepEqual(workIq.tools.find((tool) => tool.name === "list_agents")?.inputSchema.required, [])
|
|
assert.deepEqual(workIq.tools.find((tool) => tool.name === "get_schema")?.inputSchema.required, ["path", "operationType"])
|
|
assert.deepEqual(workIq.tools.find((tool) => tool.name === "search_paths")?.inputSchema.required, ["filter"])
|
|
|
|
const enterprise = profiles.find((profile) => profile.id === "microsoft-enterprise")
|
|
assert.ok(enterprise)
|
|
assert.equal(enterprise.canonicalEndpoint, "https://mcp.svc.cloud.microsoft/enterprise")
|
|
assert.equal(enterprise.endpointPath, "/enterprise")
|
|
assert.equal(enterprise.oauth.resource, "api://e8c77dc2-69b3-43f4-bc51-3213c9d915b4")
|
|
assert.equal(enterprise.oauth.audience, "api://e8c77dc2-69b3-43f4-bc51-3213c9d915b4")
|
|
assert.deepEqual(enterprise.oauth.authorizationScopes, ["api://e8c77dc2-69b3-43f4-bc51-3213c9d915b4/.default"])
|
|
assert.equal(enterprise.oauth.authorizationPath, "/organizations/oauth2/v2.0/authorize")
|
|
assert.equal(enterprise.oauth.tokenPath, "/organizations/oauth2/v2.0/token")
|
|
assert.equal(enterprise.oauth.defaultClientAuthenticationMethod, "client_secret_post")
|
|
assert.equal(enterprise.provenance.aspectFidelity.toolSchemas, "synthetic")
|
|
|
|
const mail = profiles.find((profile) => profile.id === "agent-365-mail-v1-2026-07")
|
|
assert.ok(mail)
|
|
assert.equal(mail.tools.length, 10)
|
|
assert.equal(mail.tools[0]?.name, "mcp_MailTools_graph_mail_createMessage")
|
|
assert.equal(mail.provenance.aspectFidelity.catalog, "provider-documented")
|
|
assert.equal(mail.provenance.aspectFidelity.toolSchemas, "synthetic")
|
|
|
|
const slack = profiles.find((profile) => profile.id === "slack-user-mcp")
|
|
assert.ok(slack)
|
|
assert.equal(slack.oauth.registrationPath, null)
|
|
assert.equal(slack.oauth.defaultClientAuthenticationMethod, "client_secret_post")
|
|
assert.equal(slack.oauth.tokenResponseStyle, "slack-user")
|
|
assert.deepEqual(slack.protocol.versions, ["2025-06-18"])
|
|
assert.equal(slack.protocol.versionNegotiation, "strict")
|
|
})
|
|
|
|
test("Work IQ schemas accept current documented inputs and reject the old generic path mapper", () => {
|
|
const profile = getProviderProfile("microsoft-work-iq")
|
|
const schema = (toolName: string): MockTool["inputSchema"] => {
|
|
const tool: MockTool | undefined = profile.tools.find((candidate) => candidate.name === toolName)
|
|
assert.ok(tool, `Missing Work IQ tool ${toolName}`)
|
|
return tool.inputSchema
|
|
}
|
|
|
|
assert.equal(validateToolArguments(schema("fetch"), { entityUrls: ["/me/messages"] }).success, true)
|
|
assert.equal(validateToolArguments(schema("fetch"), { path: "/me/messages" }).success, false)
|
|
assert.equal(
|
|
validateToolArguments(schema("create_entity"), {
|
|
parentUrl: "/me/messages",
|
|
jsonBody: "{\"subject\":\"Synthetic\"}",
|
|
approved: true,
|
|
idempotency_key: "work-iq-create-1",
|
|
}).success,
|
|
true,
|
|
)
|
|
assert.equal(validateToolArguments(schema("call_function"), { functionUrl: "/me/calendarView" }).success, true)
|
|
assert.equal(validateToolArguments(schema("list_agents"), {}).success, true)
|
|
assert.equal(validateToolArguments(schema("list_agents"), { query: "old invented input" }).success, false)
|
|
assert.equal(
|
|
validateToolArguments(schema("get_schema"), { path: "/me/messages", operationType: "fetch", format: "jsonschema" }).success,
|
|
true,
|
|
)
|
|
assert.equal(validateToolArguments(schema("get_schema"), { operationType: "fetch" }).success, false)
|
|
assert.equal(validateToolArguments(schema("get_schema"), { path: "/me/messages", operationType: "delete" }).success, false)
|
|
assert.equal(validateToolArguments(schema("search_paths"), { filter: ".*calendar.*" }).success, true)
|
|
})
|
|
|
|
test("fault definitions are named, phase-specific, applicable, and scenario-bound", () => {
|
|
const faults = listFaultDefinitions()
|
|
assert.equal(faults.length, 32)
|
|
assert.equal(new Set(faults.map((fault) => fault.id)).size, faults.length)
|
|
for (const fault of faults) {
|
|
assert.ok(fault.phase)
|
|
assert.ok(fault.category)
|
|
assert.ok(fault.operatorAction)
|
|
const scenario = createFaultScenario(fault.applicableProfiles[0] ?? "synthetic-enterprise-oauth-mcp", fault.id)
|
|
assert.equal(scenario.expected.firstFailedPhase, fault.phase)
|
|
assert.equal(scenario.expected.category, fault.category)
|
|
}
|
|
const dcrFault = faults.find((fault) => fault.id === "oauth-dynamic-registration-unsupported")
|
|
assert.deepEqual(dcrFault?.applicableProfiles, ["synthetic-enterprise-oauth-mcp"])
|
|
})
|
|
|
|
test("redirect URI policy accepts HTTPS plus Azure-compatible localhost and literal loopback HTTP", () => {
|
|
assert.equal(isSafeOAuthRedirectUri("https://app.example.com/oauth/callback"), true)
|
|
assert.equal(isSafeOAuthRedirectUri("http://127.0.0.1:19876/mcp/oauth/callback"), true)
|
|
assert.equal(isSafeOAuthRedirectUri("http://[::1]:19876/mcp/oauth/callback"), true)
|
|
assert.equal(isSafeOAuthRedirectUri("http://localhost:19876/callback"), true)
|
|
assert.equal(isSafeOAuthRedirectUri("http://localhost.example.com:19876/callback"), false)
|
|
assert.equal(isSafeOAuthRedirectUri("http://127.0.0.1.example.com/callback"), false)
|
|
assert.equal(isSafeOAuthRedirectUri("javascript:alert(1)"), false)
|
|
assert.equal(isSafeOAuthRedirectUri("https://user:password@app.example.com/callback"), false)
|
|
assert.equal(isSafeOAuthRedirectUri("https://app.example.com/callback#token"), false)
|
|
|
|
const base = createDefaultScenario()
|
|
assert.equal(
|
|
scenarioSchema.safeParse({ ...base, oauth: { ...base.oauth, redirectUris: ["http://localhost:19876/callback"] } }).success,
|
|
true,
|
|
)
|
|
})
|
|
|
|
test("scenario pins provider fixture, required resource scopes, and supported session behavior", () => {
|
|
const base = createDefaultScenario("microsoft-work-iq")
|
|
assert.equal(scenarioSchema.safeParse({ ...base, profileFixtureVersion: "2026-07-12.999" }).success, false)
|
|
assert.equal(
|
|
scenarioSchema.safeParse({ ...base, oauth: { ...base.oauth, requiredResourceScopes: ["offline_access"] } }).success,
|
|
false,
|
|
)
|
|
assert.equal(scenarioSchema.safeParse({ ...base, protocol: { ...base.protocol, requireSession: false } }).success, false)
|
|
})
|
|
|
|
test("bounded tool schemas support nested enterprise payloads and reject hostile schema keys", () => {
|
|
const schema = toolInputSchemaSchema.parse({
|
|
type: "object",
|
|
properties: {
|
|
message: {
|
|
type: "object",
|
|
properties: {
|
|
recipients: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
properties: { address: { type: "string", minLength: 3, maxLength: 320 } },
|
|
required: ["address"],
|
|
additionalProperties: false,
|
|
},
|
|
minItems: 1,
|
|
maxItems: 20,
|
|
},
|
|
body: { oneOf: [{ type: "string", maxLength: 10_000 }, { type: "null" }] },
|
|
},
|
|
required: ["recipients", "body"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
required: ["message"],
|
|
additionalProperties: false,
|
|
})
|
|
assert.equal(validateToolArguments(schema, {
|
|
message: { recipients: [{ address: "person@example.invalid" }], body: "hello" },
|
|
}).success, true)
|
|
assert.equal(validateToolArguments(schema, {
|
|
message: { recipients: [{ address: "x" }], body: 42 },
|
|
}).success, false)
|
|
assert.equal(toolInputSchemaSchema.safeParse({
|
|
type: "object",
|
|
properties: { constructor: { type: "__proto__" } },
|
|
required: ["constructor"],
|
|
additionalProperties: false,
|
|
}).success, false)
|
|
})
|