| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import type { AbilitySection, McpServer, User } from "@g1t/contracts"; |
| 5 | |
| 6 | import { resolveAbilities, withSetting } from "../../../packages/contracts/src/abilities.ts"; |
| 7 | import { CONNECTORS } from "../../../packages/contracts/src/connectors.ts"; |
| 8 | import { abilitiesSection, saidText } from "./abilities-prompt.ts"; |
| 9 | import { Audience, type AudienceInfo, type AudiencePorts, type RepoRef } from "./audience.ts"; |
| 10 | import { abilityCard, connectCard, requestCard } from "./card-views.ts"; |
| 11 | import { DEFAULT_AUTONOMY, applyChanges } from "./definition.ts"; |
| 12 | import { callMcpTool, listMcpTools } from "./mcp-client.ts"; |
| 13 | import { type AbilityPorts, type ActionPorts, type ToolPorts, ToolBox } from "./tools.ts"; |
| 14 | |
| 15 | // ── A small world ──────────────────────────────────────────────────────── |
| 16 | |
| 17 | const WEB: RepoRef = { id: "rep_web", namespace: "acme", name: "web", isPrivate: true, defaultBranch: "main" }; |
| 18 | const person = (id: string): User => ({ id, username: id, workspaces: [{ slug: "acme", role: "member" }] }) as User; |
| 19 | |
| 20 | function world(): AudiencePorts { |
| 21 | const info: AudienceInfo = { kind: "dm", member_user_ids: ["asker"], member_count: 1 }; |
| 22 | return { |
| 23 | info: async () => info, |
| 24 | users: async (ids) => [person("asker")].filter((u) => ids.includes(u.id)), |
| 25 | workspaceRepos: async () => [WEB], |
| 26 | readable: async (ids) => [WEB].filter((r) => ids.includes(r.id)), |
| 27 | }; |
| 28 | } |
| 29 | |
| 30 | const ports: ToolPorts = { |
| 31 | readFile: async () => null, |
| 32 | searchCode: async () => [], |
| 33 | listIssues: async () => [], |
| 34 | getIssue: async () => null, |
| 35 | getPull: async () => null, |
| 36 | recentPulls: async () => [], |
| 37 | searchMessages: async () => [], |
| 38 | readThread: async () => null, |
| 39 | roster: async () => "", |
| 40 | consult: async () => ({ ok: false, message: "no" }), |
| 41 | }; |
| 42 | |
| 43 | const actions: ActionPorts = { |
| 44 | remember: async () => ({ ok: true, message: "" }), |
| 45 | forget: async () => ({ ok: true, message: "" }), |
| 46 | draftIssue: async () => ({ ok: true, message: "" }), |
| 47 | }; |
| 48 | |
| 49 | const ITEMS: Record<string, { provider: string; key: string; title: string; url: string; status: string | null; body: string }> = { |
| 50 | "ENG-42": { provider: "linear", key: "ENG-42", title: "Retry flaky checks", url: "https://linear.app/acme/issue/ENG-42", status: "In Progress", body: "The queue retries." }, |
| 51 | "TECH-7": { provider: "jira", key: "TECH-7", title: "Login timeout", url: "https://acme.atlassian.net/browse/TECH-7", status: "To Do", body: "Times out." }, |
| 52 | }; |
| 53 | |
| 54 | type Posted = { kind: string; title: string }; |
| 55 | |
| 56 | /** Ports that record what they were asked to do. */ |
| 57 | function fakeAbilityPorts(over: Partial<AbilityPorts> = {}): AbilityPorts & { posted: Posted[]; acted: string[]; refusals: string[] } { |
| 58 | const posted: Posted[] = []; |
| 59 | const acted: string[] = []; |
| 60 | const refusals: string[] = []; |
| 61 | return { |
| 62 | posted, |
| 63 | acted, |
| 64 | refusals, |
| 65 | lookup: async (_asker, reference) => (ITEMS[reference] ? { ok: true, value: ITEMS[reference]! } : { ok: false, code: "not_found", message: "None of the acme workspace's integrations knows that." }), |
| 66 | import: async (_asker, repo, reference) => ({ ok: true, value: { number: 7, item: ITEMS[reference]!, created: true } }), |
| 67 | act: async (_asker, reference, action, text) => { |
| 68 | acted.push(`${action} ${reference}: ${text}`); |
| 69 | return { ok: true, value: ITEMS[reference]! }; |
| 70 | }, |
| 71 | askerConnected: async () => false, |
| 72 | mcp: async (server, tool, args) => { |
| 73 | acted.push(`mcp ${server.name}.${tool.name} ${JSON.stringify(args)}`); |
| 74 | return { ok: true, value: "sunny" }; |
| 75 | }, |
| 76 | askFirst: async ({ summary }) => { |
| 77 | posted.push({ kind: "ability", title: summary }); |
| 78 | return "abr_1"; |
| 79 | }, |
| 80 | connect: async (source) => { |
| 81 | posted.push({ kind: "connect", title: source.name }); |
| 82 | return true; |
| 83 | }, |
| 84 | request: async ({ connector, ability }) => { |
| 85 | posted.push({ kind: "request", title: connector ?? ability?.id ?? "" }); |
| 86 | return true; |
| 87 | }, |
| 88 | refused: ({ rule }) => { |
| 89 | refusals.push(rule); |
| 90 | }, |
| 91 | ...over, |
| 92 | }; |
| 93 | } |
| 94 | |
| 95 | const SERVER: McpServer = { |
| 96 | id: "mcp_w", |
| 97 | name: "weather", |
| 98 | url: "https://mcp.example.com/", |
| 99 | tools: [ |
| 100 | { name: "get_forecast", description: "Today's forecast", kind: "read", input_schema: { type: "object", properties: { city: { type: "string" } } } }, |
| 101 | { name: "set_alert", description: "Set an alert", kind: "write", input_schema: { type: "object", properties: {} } }, |
| 102 | ], |
| 103 | added_by: "ana", |
| 104 | added_at: "2026-10-10T00:00:00.000Z", |
| 105 | checked_at: null, |
| 106 | problem: null, |
| 107 | }; |
| 108 | |
| 109 | async function box(input: { connected?: string[]; settings?: Record<string, { level?: "alone" | "asked" | "ask" | "never"; credentials?: "workspace" | "asker" }>; servers?: McpServer[]; said?: string; session?: boolean; ports?: Partial<AbilityPorts> }) { |
| 110 | const audience = await Audience.build("acme", "asker", world()); |
| 111 | const tools = new ToolBox(audience, ports, { agentId: "agt_me", notConsult: ["me"], hops: 0, maxHops: 6, session: input.session }, [], actions); |
| 112 | let abilities = { settings: {}, mcp_servers: input.servers ?? [] }; |
| 113 | for (const [id, setting] of Object.entries(input.settings ?? {})) abilities = withSetting(abilities, id, setting); |
| 114 | const sections: AbilitySection[] = resolveAbilities({ connectors: CONNECTORS, abilities, autonomy: DEFAULT_AUTONOMY, connected: input.connected ?? [] }); |
| 115 | const fake = fakeAbilityPorts(input.ports); |
| 116 | tools.useAbilities(sections, fake, input.said ?? "", input.servers ?? []); |
| 117 | return { tools, fake, sections }; |
| 118 | } |
| 119 | |
| 120 | const names = (tools: ToolBox) => tools.definitions().map((t) => t.name); |
| 121 | |
| 122 | // ── Offering ───────────────────────────────────────────────────────────── |
| 123 | |
| 124 | test("nothing connected: no outside tools but request_ability; a connected Linear offers lookup, import and act", async () => { |
| 125 | const none = await box({}); |
| 126 | assert.ok(!names(none.tools).includes("lookup_outside")); |
| 127 | assert.ok(names(none.tools).includes("request_ability"), "it can still ask for what it lacks"); |
| 128 | const linear = await box({ connected: ["linear"] }); |
| 129 | for (const tool of ["lookup_outside", "import_outside", "act_outside"]) assert.ok(names(linear.tools).includes(tool), tool); |
| 130 | }); |
| 131 | |
| 132 | test("an ability set to Never isn't offered when it is the only one for its tool, and is refused with the rule if called anyway", async () => { |
| 133 | const { tools, fake } = await box({ connected: ["linear"], settings: { "integration:linear:comment": { level: "never" } } }); |
| 134 | assert.ok(!names(tools).includes("act_outside"), "Linear's only act ability is Never"); |
| 135 | const tried = await tools.run("act_outside", { reference: "ENG-42", action: "comment", text: "hi" }); |
| 136 | assert.equal(tried.outcome, "refused"); |
| 137 | assert.match(tried.text, /no tool called act_outside/); |
| 138 | assert.deepEqual(fake.acted, []); |
| 139 | }); |
| 140 | |
| 141 | test("Never on one system, allowed on another: the tool is offered, and the rule is named per call, in the result and the audit log", async () => { |
| 142 | const { tools, fake } = await box({ connected: ["linear", "jira"], settings: { "integration:linear:comment": { level: "never" }, "integration:jira:comment": { level: "alone" } } }); |
| 143 | assert.ok(names(tools).includes("act_outside")); |
| 144 | const linear = await tools.run("act_outside", { reference: "ENG-42", action: "comment", text: "Looks fixed." }); |
| 145 | assert.equal(linear.outcome, "refused"); |
| 146 | assert.match(linear.text, /Not allowed: your abilities say "Linear: Comment" is Never/); |
| 147 | assert.deepEqual(fake.refusals, ["integration:linear:comment=never"]); |
| 148 | assert.deepEqual(fake.acted, [], "nothing ran"); |
| 149 | const jira = await tools.run("act_outside", { reference: "TECH-7", action: "comment", text: "On it." }); |
| 150 | assert.equal(jira.outcome, "allowed"); |
| 151 | assert.deepEqual(fake.acted, ["comment TECH-7: On it."]); |
| 152 | assert.equal(tools.calls.length, 2, "both calls are in the transcript"); |
| 153 | assert.equal(tools.calls[0]!.outcome, "refused"); |
| 154 | }); |
| 155 | |
| 156 | test("reading is alone by default; an item nobody knows is not found, and a system that isn't connected is said so", async () => { |
| 157 | const { tools } = await box({ connected: ["linear"] }); |
| 158 | const read = await tools.run("lookup_outside", { reference: "ENG-42" }); |
| 159 | assert.equal(read.outcome, "allowed"); |
| 160 | assert.match(read.text, /<untrusted source="Linear ENG-42">/); |
| 161 | assert.match(read.text, /Retry flaky checks/); |
| 162 | const missing = await tools.run("lookup_outside", { reference: "NOPE-1" }); |
| 163 | assert.equal(missing.outcome, "refused"); |
| 164 | assert.match(missing.text, /No connected integration knows NOPE-1/); |
| 165 | // Jira knows TECH-7, but Jira isn't connected to this workspace as the agent sees it. |
| 166 | const jira = await tools.run("lookup_outside", { reference: "TECH-7" }); |
| 167 | assert.equal(jira.outcome, "refused"); |
| 168 | assert.match(jira.text, /Jira isn't connected to this workspace, so you can't read tickets there/); |
| 169 | }); |
| 170 | |
| 171 | // ── Ask first, and alone when asked for it ─────────────────────────────── |
| 172 | |
| 173 | test("Ask first posts the card with the call, runs nothing, and tells the agent to wait; a session is told it pauses", async () => { |
| 174 | const { tools, fake } = await box({ connected: ["linear"], session: true }); |
| 175 | const tried = await tools.run("act_outside", { reference: "ENG-42", action: "comment", text: "Fixed in #412." }); |
| 176 | assert.equal(tried.outcome, "refused"); |
| 177 | assert.match(tried.text, /"Linear: Comment" is after asking first: a card was posted asking @asker \(or an owner\)/); |
| 178 | assert.match(tried.text, /Your session pauses/); |
| 179 | assert.deepEqual(fake.posted, [{ kind: "ability", title: "Comment on ENG-42 in Linear" }]); |
| 180 | assert.deepEqual(fake.acted, []); |
| 181 | assert.deepEqual(fake.refusals, ["integration:linear:comment=ask"]); |
| 182 | }); |
| 183 | |
| 184 | test("alone when asked for it: runs when the person named the item, asks first when they didn't", async () => { |
| 185 | const asked = await box({ connected: ["linear"], said: "Please import ENG-42 into web so we can track it here." }); |
| 186 | const ran = await asked.tools.run("import_outside", { repo: "web", reference: "ENG-42" }); |
| 187 | assert.equal(ran.outcome, "allowed"); |
| 188 | assert.match(ran.text, /Opened acme\/web#7 from ENG-42/); |
| 189 | assert.deepEqual(asked.fake.posted, []); |
| 190 | const unasked = await box({ connected: ["linear"], said: "What's the state of the queue work?" }); |
| 191 | const held = await unasked.tools.run("import_outside", { repo: "web", reference: "ENG-42" }); |
| 192 | assert.equal(held.outcome, "refused"); |
| 193 | assert.match(held.text, /^Import issues in Linear runs on its own only when asked for it, and this wasn't\. "Linear: Import issues" is only when the person asked for that: a card was posted/); |
| 194 | assert.deepEqual(unasked.fake.posted, [{ kind: "ability", title: "Import ENG-42 from Linear into acme/web" }]); |
| 195 | }); |
| 196 | |
| 197 | test("when the card can't be posted, the agent is told to ask in words", async () => { |
| 198 | const { tools } = await box({ connected: ["linear"], ports: { askFirst: async () => null } }); |
| 199 | const tried = await tools.run("act_outside", { reference: "ENG-42", action: "comment", text: "x" }); |
| 200 | assert.match(tried.text, /couldn't be posted just now/); |
| 201 | }); |
| 202 | |
| 203 | // ── Whose connection ───────────────────────────────────────────────────── |
| 204 | |
| 205 | test("the asker's own connection, missing: a Connect card, nothing runs, and the audit log says why", async () => { |
| 206 | const { tools, fake } = await box({ connected: ["linear"], settings: { "integration:linear:read": { credentials: "asker" } } }); |
| 207 | const tried = await tools.run("lookup_outside", { reference: "ENG-42" }); |
| 208 | assert.equal(tried.outcome, "refused"); |
| 209 | assert.match(tried.text, /runs on @asker's own Linear connection, and they haven't connected one. A Connect card was posted/); |
| 210 | assert.deepEqual(fake.posted, [{ kind: "connect", title: "Linear" }]); |
| 211 | assert.deepEqual(fake.refusals, ["integration:linear:read=asker-not-connected"]); |
| 212 | const connected = await box({ connected: ["linear"], settings: { "integration:linear:read": { credentials: "asker" } }, ports: { askerConnected: async () => true } }); |
| 213 | assert.equal((await connected.tools.run("lookup_outside", { reference: "ENG-42" })).outcome, "allowed"); |
| 214 | }); |
| 215 | |
| 216 | // ── Requests ───────────────────────────────────────────────────────────── |
| 217 | |
| 218 | test("request_ability posts a Request card for an integration that isn't connected, or an ability by its id", async () => { |
| 219 | const { tools, fake } = await box({ connected: ["linear"], settings: { "integration:linear:comment": { level: "never" } } }); |
| 220 | const jira = await tools.run("request_ability", { needs: "jira", why: "Support files bugs there." }); |
| 221 | assert.equal(jira.outcome, "allowed"); |
| 222 | const comment = await tools.run("request_ability", { needs: "integration:linear:comment", why: "To tell the team when it's fixed." }); |
| 223 | assert.equal(comment.outcome, "allowed"); |
| 224 | assert.deepEqual(fake.posted, [ |
| 225 | { kind: "request", title: "jira" }, |
| 226 | { kind: "request", title: "integration:linear:comment" }, |
| 227 | ]); |
| 228 | const bad = await tools.run("request_ability", { needs: "", why: "" }); |
| 229 | assert.equal(bad.outcome, "refused"); |
| 230 | }); |
| 231 | |
| 232 | // ── MCP ────────────────────────────────────────────────────────────────── |
| 233 | |
| 234 | test("an MCP server's tools are offered as <server>__<tool> with the server's schema; reads run alone, writes ask first, Never withholds", async () => { |
| 235 | const { tools, fake } = await box({ servers: [SERVER] }); |
| 236 | const forecast = tools.definitions().find((t) => t.name === "weather__get_forecast"); |
| 237 | assert.ok(forecast, "offered"); |
| 238 | assert.deepEqual(forecast!.input_schema, SERVER.tools[0]!.input_schema); |
| 239 | assert.match(forecast!.description, /it reads/); |
| 240 | const read = await tools.run("weather__get_forecast", { city: "Lisbon" }); |
| 241 | assert.equal(read.outcome, "allowed"); |
| 242 | assert.match(read.text, /<untrusted source="weather get_forecast">\nsunny/); |
| 243 | const write = await tools.run("weather__set_alert", {}); |
| 244 | assert.equal(write.outcome, "refused"); |
| 245 | assert.match(write.text, /"weather: set_alert" is after asking first/); |
| 246 | assert.deepEqual(fake.posted, [{ kind: "ability", title: "Call set_alert on weather" }]); |
| 247 | const never = await box({ servers: [SERVER], settings: { "mcp:mcp_w:set_alert": { level: "never" } } }); |
| 248 | assert.ok(!names(never.tools).includes("weather__set_alert"), "not offered at all"); |
| 249 | assert.equal((await never.tools.run("weather__set_alert", {})).outcome, "refused"); |
| 250 | }); |
| 251 | |
| 252 | // ── The prompt and what was said ───────────────────────────────────────── |
| 253 | |
| 254 | test("the prompt's abilities section names each connected system's rows and levels, and what isn't connected", async () => { |
| 255 | const { sections } = await box({ connected: ["linear"], settings: { "integration:linear:comment": { level: "never" } }, servers: [SERVER] }); |
| 256 | const text = abilitiesSection(sections)!; |
| 257 | assert.match(text, /- Linear: read issues on your own; import issues on your own only when they asked for it, else it asks first; comment never\./); |
| 258 | assert.match(text, /- weather: get_forecast on your own; set_alert asks first \(a card\)\./); |
| 259 | assert.match(text, /Not connected to this workspace: Jira, Sentry\./); |
| 260 | assert.equal(saidText([null, " ", "a", "b"]), "a\nb"); |
| 261 | }); |
| 262 | |
| 263 | // ── The definition ─────────────────────────────────────────────────────── |
| 264 | |
| 265 | test("a definition keeps ability settings it knows, within each kind's limit, and never above Ask for a restricted one", () => { |
| 266 | const base = applyChanges(null, { handle: "margo", display_name: "Margo", title: "QA", instructions: "Test." }, []); |
| 267 | assert.ok(base.ok); |
| 268 | const changed = applyChanges(base.value, { abilities: { settings: { "integration:linear:comment": { level: "alone", credentials: "asker" } } } }, []); |
| 269 | assert.ok(changed.ok); |
| 270 | assert.deepEqual(changed.value.abilities, { settings: { "integration:linear:comment": { level: "alone", credentials: "asker" } }, mcp_servers: [] }); |
| 271 | const unknown = applyChanges(base.value, { abilities: { settings: { "integration:slack:post": { level: "alone" } } } }, []); |
| 272 | assert.ok(!unknown.ok && /no ability called integration:slack:post/.test(unknown.message)); |
| 273 | const badLevel = applyChanges(base.value, { abilities: { settings: { "integration:linear:read": { level: "loud" as never } } } }, []); |
| 274 | assert.ok(!badLevel.ok); |
| 275 | const notIntegration = applyChanges(base.value, { abilities: { settings: { "mcp:mcp_w:get_forecast": { level: "ask" } } } }, []); |
| 276 | assert.ok(!notIntegration.ok, "no such server on the agent"); |
| 277 | // With the server, its tools are abilities; removing the server drops their settings. |
| 278 | const withServer = applyChanges(base.value, { abilities: { settings: { "mcp:mcp_w:get_forecast": { level: "ask" } } } }, [], { mcp_servers: [SERVER] }); |
| 279 | assert.ok(withServer.ok); |
| 280 | assert.equal(withServer.value.abilities.mcp_servers.length, 1); |
| 281 | assert.deepEqual(withServer.value.abilities.settings, { "mcp:mcp_w:get_forecast": { level: "ask" } }); |
| 282 | const without = applyChanges(withServer.value, {}, [], { mcp_servers: [] }); |
| 283 | assert.ok(without.ok); |
| 284 | assert.deepEqual(without.value.abilities, { settings: {}, mcp_servers: [] }); |
| 285 | const g1t = applyChanges(base.value, { abilities: { settings: { "g1t:merge": { level: "alone" } } } }, []); |
| 286 | assert.ok(!g1t.ok, "g1t's own keep their choices in autonomy"); |
| 287 | }); |
| 288 | |
| 289 | // ── Cards ──────────────────────────────────────────────────────────────── |
| 290 | |
| 291 | test("the Ask-first, Connect and Request cards say what they are for and offer the right buttons", () => { |
| 292 | const waiting = abilityCard({ id: "abr_1", summary: "Comment on ENG-42 in Linear", status: "pending", decided_by: null, result: null, ability: "integration:linear:comment" }, { agent: "Margo", asker: "ana", rule: "Linear: Comment", level: "ask", note: null, body: "Fixed in #412." }); |
| 293 | assert.equal(waiting.state, "Waiting"); |
| 294 | assert.deepEqual(waiting.actions?.map((a) => a.id), ["allow", "deny"]); |
| 295 | assert.equal(waiting.owner, "agents"); |
| 296 | assert.equal(waiting.body, "Fixed in #412."); |
| 297 | const done = abilityCard({ id: "abr_1", summary: "Comment on ENG-42 in Linear", status: "allowed", decided_by: "ana", result: "Commented on ENG-42.", ability: "integration:linear:comment" }, { agent: "Margo", asker: "ana", rule: "Linear: Comment", level: "ask", note: null, body: null }); |
| 298 | assert.equal(done.state, "Done"); |
| 299 | assert.equal(done.actions, undefined); |
| 300 | const connect = connectCard({ connector: "Linear", agent: "Margo", ability: "Read issues", asker: "ana", href: "/settings/integrations" }); |
| 301 | assert.equal(connect.actions?.[0]?.href, "/settings/integrations"); |
| 302 | assert.equal(connect.owner, null, "a link, no action for the service"); |
| 303 | const request = requestCard({ agent: { id: "a1", handle: "margo", display_name: "Margo" }, workspace: "acme", connector: { id: "jira", name: "Jira", available: true }, ability: null, why: "Support files bugs there.", status: "open", by: null }); |
| 304 | assert.deepEqual(request.actions?.map((a) => a.id), ["ask", "open"]); |
| 305 | assert.equal(request.ref, "connector:a1:jira"); |
| 306 | const asked = requestCard({ agent: { id: "a1", handle: "margo", display_name: "Margo" }, workspace: "acme", connector: null, ability: { id: "integration:linear:comment", label: "Linear: Comment" }, why: "x", status: "asked", by: "bo" }); |
| 307 | assert.equal(asked.state, "Asked"); |
| 308 | assert.equal(asked.actions?.[0]?.href, "/acme/-/agents/margo/abilities"); |
| 309 | }); |
| 310 | |
| 311 | // ── The MCP client ─────────────────────────────────────────────────────── |
| 312 | |
| 313 | test("the MCP client lists tools (reads by readOnlyHint) and calls one, over JSON or an event stream", async () => { |
| 314 | const seen: { method: string; session: string | null }[] = []; |
| 315 | const fetchFn = async (_url: string, init: RequestInit) => { |
| 316 | const body = JSON.parse(String(init.body)) as { id?: number; method: string; params?: { name?: string } }; |
| 317 | seen.push({ method: body.method, session: (init.headers as Record<string, string>)["mcp-session-id"] ?? null }); |
| 318 | const answer = (result: unknown, sse = false) => |
| 319 | new Response(sse ? `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: body.id, result })}\n\n` : JSON.stringify({ jsonrpc: "2.0", id: body.id, result }), { |
| 320 | status: 200, |
| 321 | headers: { "content-type": sse ? "text/event-stream" : "application/json", "mcp-session-id": "s1" }, |
| 322 | }); |
| 323 | if (body.method === "initialize") return answer({ protocolVersion: "2025-06-18" }); |
| 324 | if (body.method === "notifications/initialized") return new Response(null, { status: 202 }); |
| 325 | if (body.method === "tools/list") { |
| 326 | return answer( |
| 327 | { |
| 328 | tools: [ |
| 329 | { name: "get_forecast", description: "Forecast", inputSchema: { type: "object", properties: { city: { type: "string" } } }, annotations: { readOnlyHint: true } }, |
| 330 | { name: "set_alert", description: "Alert" }, |
| 331 | { name: "bad name!" }, |
| 332 | ], |
| 333 | }, |
| 334 | true, |
| 335 | ); |
| 336 | } |
| 337 | if (body.method === "tools/call") return answer({ content: [{ type: "text", text: `sunny in ${JSON.stringify(body.params)}` }] }); |
| 338 | return new Response("{}", { status: 404 }); |
| 339 | }; |
| 340 | const listed = await listMcpTools("https://mcp.example.com/", fetchFn); |
| 341 | assert.ok(listed.ok); |
| 342 | assert.deepEqual( |
| 343 | listed.tools.map((t) => [t.name, t.kind]), |
| 344 | [ |
| 345 | ["get_forecast", "read"], |
| 346 | ["set_alert", "write"], |
| 347 | ], |
| 348 | ); |
| 349 | assert.deepEqual(listed.tools[1]!.input_schema, { type: "object", properties: {} }); |
| 350 | assert.equal(seen[2]!.session, "s1", "the session id the server gave rides along"); |
| 351 | const called = await callMcpTool("https://mcp.example.com/", "get_forecast", { city: "Lisbon" }, fetchFn); |
| 352 | assert.ok(called.ok); |
| 353 | assert.match(called.text, /sunny in \{"name":"get_forecast","arguments":\{"city":"Lisbon"\}\}/); |
| 354 | const down = await listMcpTools("https://mcp.example.com/", async () => { |
| 355 | throw new Error("ECONNREFUSED"); |
| 356 | }); |
| 357 | assert.ok(!down.ok && /couldn't be reached/.test(down.message)); |
| 358 | const errored = await callMcpTool("https://mcp.example.com/", "x", {}, async (_u, init) => { |
| 359 | const body = JSON.parse(String(init.body)) as { id?: number; method: string }; |
| 360 | if (body.method === "tools/call") return Response.json({ jsonrpc: "2.0", id: body.id, result: { isError: true, content: [{ type: "text", text: "no such city" }] } }); |
| 361 | return Response.json({ jsonrpc: "2.0", id: body.id, result: {} }); |
| 362 | }); |
| 363 | assert.ok(!errored.ok && errored.message === "no such city"); |
| 364 | }); |