diff --git a/workers/api/src/lib/connectors/http.test.ts b/workers/api/src/lib/connectors/http.test.ts new file mode 100644 index 00000000..c37a4d33 --- /dev/null +++ b/workers/api/src/lib/connectors/http.test.ts @@ -0,0 +1,234 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getRegistryTool } from "../tool-registry.js"; +import type { RegistryToolCtx } from "../tool-registry.js"; +import type { ConnectorClient } from "./client.js"; + +// The http_request tool, resolved from the registry (proves it's registered → callable via +// runtime, MCP proxy, and POST …/tools/http_request with no bespoke route). +const httpRequest = getRegistryTool("http_request")!; + +// A ctx with no vault (api-key tests inject their own connectorClient). +const baseCtx = { env: {} as any } as RegistryToolCtx; + +/** Mock globalThis.fetch (what safeFetch calls). Records the URL + init it was given. */ +function mockFetch(status: number, body: unknown) { + const calls: Array<{ url: string; init: RequestInit }> = []; + const spy = vi.spyOn(globalThis, "fetch").mockImplementation(async (url: any, init: any) => { + calls.push({ url: String(url), init: init || {} }); + return new Response(typeof body === "string" ? body : JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + }); + return { calls, spy }; +} + +afterEach(() => vi.restoreAllMocks()); + +async function run(input: Record, ctx: RegistryToolCtx = baseCtx) { + const r = await httpRequest.handler(ctx, input); + return { ...r, parsed: r.success || r.content.startsWith("{") ? safeParse(r.content) : undefined }; +} +function safeParse(s: string): any { + try { + return JSON.parse(s); + } catch { + return undefined; + } +} + +describe("http_request — registration & schema", () => { + it("is registered as an http-connector, read-scoped tool", () => { + expect(httpRequest.connector).toBe("http"); + expect(httpRequest.tier).toBe("connector"); + expect(httpRequest.scope).toBe("read"); + }); + it("exposes method/url/base/path/query/headers/body/auth/responseMap/pagination in its schema", () => { + const p = httpRequest.jsonSchema.properties; + for (const k of ["method", "url", "base", "path", "query", "headers", "body", "auth", "responseMap", "pagination"]) { + expect(p[k]).toBeDefined(); + } + expect(httpRequest.jsonSchema.type).toBe("object"); + }); + it("errors (not throws) when neither url nor base is supplied", async () => { + const r = await run({ method: "GET" }); + expect(r.success).toBe(false); + expect(r.content).toMatch(/url.*base/i); + }); +}); + +describe("http_request — {{param}} interpolation", () => { + it("interpolates url, query, headers, and body from inputs", async () => { + const { calls } = mockFetch(200, { ok: true }); + await run({ + method: "POST", + url: "https://api.example.com/{{version}}/search", + query: { q: "{{term}}" }, + headers: { "X-Trace": "{{trace}}" }, + body: { text: "{{term}}", n: "{{limit}}" }, + inputs: { version: "v1", term: "coffee", trace: "abc", limit: 5 }, + }); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe("https://api.example.com/v1/search?q=coffee"); + expect((calls[0].init.headers as Headers).get("X-Trace")).toBe("abc"); + expect(JSON.parse(calls[0].init.body as string)).toEqual({ text: "coffee", n: "5" }); + }); + it("joins base + path and drops empty query params", async () => { + const { calls } = mockFetch(200, {}); + await run({ base: "https://api.example.com/", path: "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/v1/thing", query: { a: "", b: "keep" } }); + expect(calls[0].url).toBe("https://api.example.com/v1/thing?b=keep"); + }); + it("a missing input renders as empty, not the literal {{x}}", async () => { + const { calls } = mockFetch(200, {}); + await run({ url: "https://api.example.com/x?p={{missing}}" }); + expect(calls[0].url).not.toContain("{{"); + }); +}); + +describe("http_request — responseMap extraction", () => { + const places = { + places: [ + { id: "1", displayName: { text: "Cafe A" }, websiteUri: "https://a.example" }, + { id: "2", displayName: { text: "Cafe B" }, websiteUri: null }, + ], + nextPageToken: "TOKEN123", + }; + it("projects an array with a reshape spec (aliased dotted sub-paths)", async () => { + mockFetch(200, places); + const r = await run({ + url: "https://places.example/search", + responseMap: "places[].{id,name:displayName.text,site:websiteUri}", + }); + expect(r.parsed.data).toEqual([ + { id: "1", name: "Cafe A", site: "https://a.example" }, + { id: "2", name: "Cafe B", site: null }, + ]); + }); + it("resolves a plain dotted path (with array index)", async () => { + mockFetch(200, places); + const r = await run({ url: "https://places.example/search", responseMap: "places.0.displayName.text" }); + expect(r.parsed.data).toBe("Cafe A"); + }); + it("returns raw body alongside mapped data when includeRaw is set", async () => { + mockFetch(200, places); + const r = await run({ url: "https://places.example/search", responseMap: "places[].id", includeRaw: true }); + expect(r.parsed.data).toEqual(["1", "2"]); + expect(r.parsed.raw.nextPageToken).toBe("TOKEN123"); + }); +}); + +describe("http_request — pagination descriptor", () => { + it("surfaces the next-page marker as nextCursor", async () => { + mockFetch(200, { items: [], nextPageToken: "PAGE2" }); + const r = await run({ + url: "https://api.example.com/list", + pagination: { type: "nextPageToken", path: "nextPageToken" }, + }); + expect(r.parsed.nextCursor).toBe("PAGE2"); + expect(r.parsed.paginationType).toBe("nextPageToken"); + }); + it("nextCursor is null when the marker is absent", async () => { + mockFetch(200, { items: [] }); + const r = await run({ url: "https://api.example.com/list", pagination: { type: "offset", path: "next" } }); + expect(r.parsed.nextCursor).toBeNull(); + }); +}); + +describe("http_request — api-key from vault (mocked connectorClient)", () => { + // A connectorClient whose token() returns the vault key. Asserts the KEY never appears + // in inputs/schema and is injected onto the wire only. + function ctxWithKey(key: string): RegistryToolCtx { + const client = { token: async () => key } as unknown as ConnectorClient; + return { env: {} as any, connectorClient: () => client } as RegistryToolCtx; + } + it("injects the vault key into a configurable request header (Google Places X-Goog-Api-Key)", async () => { + const { calls } = mockFetch(200, { places: [] }); + await run( + { url: "https://places.googleapis.com/v1/places:searchText", method: "POST", auth: { mode: "api-key", key: { in: "header", name: "X-Goog-Api-Key" } }, body: {} }, + ctxWithKey("SECRET_KEY"), + ); + expect((calls[0].init.headers as Headers).get("X-Goog-Api-Key")).toBe("SECRET_KEY"); + }); + it("injects the vault key into a configurable query param", async () => { + const { calls } = mockFetch(200, {}); + await run( + { url: "https://maps.googleapis.com/maps/api/geocode/json?address=Sydney", auth: { mode: "api-key", key: { in: "query", name: "key" } } }, + ctxWithKey("SECRET_KEY"), + ); + expect(calls[0].url).toContain("key=SECRET_KEY"); + }); + it("fails cleanly (no request) when api-key mode is set but no key is connected", async () => { + const { calls } = mockFetch(200, {}); + const noKey = { env: {} as any, connectorClient: () => ({ token: async () => "" }) as any } as RegistryToolCtx; + const r = await run({ url: "https://places.googleapis.com/v1/x", auth: { mode: "api-key", key: { in: "header", name: "X-Goog-Api-Key" } } }, noKey); + expect(r.success).toBe(false); + expect(calls).toHaveLength(0); + }); + it("never echoes the key into the returned result", async () => { + mockFetch(200, { ok: true }); + const r = await run( + { url: "https://places.googleapis.com/v1/x", auth: { mode: "api-key", key: { in: "header", name: "X-Goog-Api-Key" } } }, + ctxWithKey("SECRET_KEY"), + ); + expect(r.content).not.toContain("SECRET_KEY"); + }); +}); + +describe("http_request — SSRF safety (uses safeFetch)", () => { + it("rejects a non-public target without hitting the network", async () => { + const { calls } = mockFetch(200, {}); + const r = await run({ url: "https://169.254.169.254/latest/meta-data/" }); + expect(r.success).toBe(false); + expect(r.content).toMatch(/blocked/i); + expect(calls).toHaveLength(0); // safeFetch threw SsrfError before fetching + }); + it("rejects an http:// (non-https) URL", async () => { + const { calls } = mockFetch(200, {}); + const r = await run({ url: "http://api.example.com/x" }); + expect(r.success).toBe(false); + expect(calls).toHaveLength(0); + }); +}); + +describe("http_request — Google Places searchText, purely as config (the #95 proof)", () => { + it("expresses a full Places searchText call — url, key-from-vault, JSON body, responseMap — with zero bespoke code", async () => { + const placesResponse = { + places: [ + { id: "p1", displayName: { text: "Blue Bottle" }, websiteUri: "https://bluebottle.example" }, + { id: "p2", displayName: { text: "No Site Cafe" } }, + ], + }; + const { calls } = mockFetch(200, placesResponse); + const client = { token: async () => "PLACES_KEY" } as unknown as ConnectorClient; + const ctx = { env: {} as any, connectorClient: () => client } as RegistryToolCtx; + + const r = await run( + { + method: "POST", + url: "https://places.googleapis.com/v1/places:searchText", + auth: { mode: "api-key", key: { in: "header", name: "X-Goog-Api-Key" } }, + headers: { "X-Goog-FieldMask": "places.id,places.displayName,places.websiteUri" }, + body: { textQuery: "{{query}}", maxResultCount: "{{max}}" }, + inputs: { query: "cafes in Sydney", max: 20 }, + responseMap: "places[].{id,name:displayName.text,site:websiteUri}", + }, + ctx, + ); + + // Right endpoint, method, auth header, field mask, and templated JSON body. + expect(calls[0].url).toBe("https://places.googleapis.com/v1/places:searchText"); + expect(calls[0].init.method).toBe("POST"); + const h = calls[0].init.headers as Headers; + expect(h.get("X-Goog-Api-Key")).toBe("PLACES_KEY"); + expect(h.get("X-Goog-FieldMask")).toBe("places.id,places.displayName,places.websiteUri"); + expect(JSON.parse(calls[0].init.body as string)).toEqual({ textQuery: "cafes in Sydney", maxResultCount: "20" }); + + // Mapped result — exactly the typed shape a lead-finder source step consumes. + expect(r.success).toBe(true); + expect(r.parsed.status).toBe(200); + expect(r.parsed.data).toEqual([ + { id: "p1", name: "Blue Bottle", site: "https://bluebottle.example" }, + { id: "p2", name: "No Site Cafe", site: null }, + ]); + }); +}); diff --git a/workers/api/src/lib/connectors/http.ts b/workers/api/src/lib/connectors/http.ts new file mode 100644 index 00000000..f54788f3 --- /dev/null +++ b/workers/api/src/lib/connectors/http.ts @@ -0,0 +1,260 @@ +// Generic HTTP/REST connector (issue #95). The piece that makes "call any REST API" a +// CONFIGURATION, not bespoke Worker code: ONE `http_request` tool whose method, url, +// query, headers, and body are all templated (`{{param}}` from the caller's inputs), with +// an optional dotted responseMap extraction and an optional pagination descriptor. +// +// Auth (declared on the connector as auth:"token", grantModel:"user"): +// • none — no credential injected. +// • api-key — a vault-stored key (user_api_keys, provider "http") minted via the +// connectorClient (#86) and injected into a CONFIGURABLE header or query +// param (e.g. Google Places `X-Goog-Api-Key`). NEVER inlined or logged. +// +// Every outbound call goes through `safeFetch` (SSRF guard, https-only, redirect-revalidated), +// so a templated/attacker-influenced url can't reach cloud-metadata / loopback / RFC1918. +import type { ToolDef, RegistryToolCtx } from "../tool-registry.js"; +import { safeFetch, SsrfError } from "../ssrf.js"; + +// ── {{param}} interpolation ──────────────────────────────────────────────── +// Replace every {{name}} with String(inputs[name]). A missing input becomes "" (so a +// template with an unused optional slot doesn't leak the literal "{{x}}"). Applied to +// strings and recursively into query/headers/body values. +function interpolate(template: string, inputs: Record): string { + return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_m, key: string) => { + const v = inputs[key]; + return v === undefined || v === null ? "" : String(v); + }); +} + +function interpolateDeep(value: unknown, inputs: Record): unknown { + if (typeof value === "string") return interpolate(value, inputs); + if (Array.isArray(value)) return value.map((v) => interpolateDeep(v, inputs)); + if (value && typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) out[k] = interpolateDeep(v, inputs); + return out; + } + return value; +} + +// ── responseMap: simple JSONPath-ish dotted extraction ────────────────────── +// Grammar (deliberately tiny — no third-party JSONPath dep): +// • dotted path "candidates.0.content" → nested lookup ([] index allowed) +// • array-projection "places[].displayName.text" → map each element to that sub-path +// • projection with reshape "places[].{id,name:displayName.text,site:websiteUri}" +// → [{id, name, site}, …] pulling each field's dotted sub-path +// Returns undefined for a path that doesn't resolve (rather than throwing) so a partial +// response maps to nulls, not an error. +function getPath(obj: unknown, path: string): unknown { + if (!path) return obj; + let cur: unknown = obj; + for (const seg of path.split(".")) { + if (cur === null || cur === undefined) return undefined; + if (Array.isArray(cur)) { + const idx = Number(seg); + cur = Number.isInteger(idx) ? cur[idx] : undefined; + } else if (typeof cur === "object") { + cur = (cur as Record)[seg]; + } else { + return undefined; + } + } + return cur; +} + +// Parse the "{a,b:path,c:path}" reshape spec into [outKey, subPath] pairs. +function parseReshape(spec: string): Array<[string, string]> { + return spec + .slice(1, -1) // drop the braces + .split(",") + .map((f) => f.trim()) + .filter(Boolean) + .map((f) => { + const colon = f.indexOf(":"); + if (colon === -1) return [f, f] as [string, string]; // "id" → out "id" from sub-path "id" + return [f.slice(0, colon).trim(), f.slice(colon + 1).trim()] as [string, string]; + }); +} + +function applyResponseMap(data: unknown, map: string): unknown { + const m = map.trim(); + const arrIdx = m.indexOf("[]"); + if (arrIdx === -1) return getPath(data, m); // plain dotted path + + const beforeArr = m.slice(0, arrIdx); // path to the array + let rest = m.slice(arrIdx + 2); // what to pull from each element + if (rest.startsWith(".")) rest = rest.slice(1); + + const arr = getPath(data, beforeArr); + if (!Array.isArray(arr)) return []; + + if (rest.startsWith("{") && rest.endsWith("}")) { + const fields = parseReshape(rest); + return arr.map((el) => { + const row: Record = {}; + for (const [outKey, subPath] of fields) row[outKey] = getPath(el, subPath) ?? null; + return row; + }); + } + // projection of a single sub-path (or the element itself when rest === "") + return arr.map((el) => (rest ? (getPath(el, rest) ?? null) : el)); +} + +// ── url assembly ──────────────────────────────────────────────────────────── +function buildUrl(input: Record, inputs: Record): string { + const rawUrl = typeof input.url === "string" && input.url ? interpolate(input.url, inputs) : ""; + const base = typeof input.base === "string" ? interpolate(input.base, inputs) : ""; + const path = typeof input.path === "string" ? interpolate(input.path, inputs) : ""; + let url = rawUrl || (base ? base.replace(/\/+$/, "") + (path ? "/" + path.replace(/^\/+/, "") : "") : ""); + if (!url) throw new Error("Provide `url`, or `base` (+ optional `path`)."); + + const query = input.query && typeof input.query === "object" && !Array.isArray(input.query) + ? (interpolateDeep(input.query, inputs) as Record) + : undefined; + if (query && Object.keys(query).length) { + const u = new URL(url); + for (const [k, v] of Object.entries(query)) { + if (v !== undefined && v !== null && String(v) !== "") u.searchParams.set(k, String(v)); + } + url = u.toString(); + } + return url; +} + +// ── api-key injection (from the vault, via connectorClient) ────────────────── +// auth = { mode: "api-key", key: { in: "header"|"query", name: "X-Goog-Api-Key" } }. +// The key VALUE never appears in inputs, the tool schema, or the returned result — it's +// read from user_api_keys (provider "http") through ctx.connectorClient("http").token() +// and attached to the outgoing request only. +interface ApiKeyAuth { + mode: "api-key"; + key: { in: "header" | "query"; name: string }; +} +type HttpAuth = { mode: "none" } | ApiKeyAuth; + +function parseAuth(raw: unknown): HttpAuth { + if (!raw || typeof raw !== "object") return { mode: "none" }; + const a = raw as Record; + if (a.mode === "api-key") { + const key = a.key as Record | undefined; + const where = key?.in === "query" ? "query" : "header"; + const name = typeof key?.name === "string" ? key.name : ""; + if (!name) throw new Error("auth.key.name is required for api-key mode."); + return { mode: "api-key", key: { in: where, name } }; + } + return { mode: "none" }; +} + +export const HTTP_TOOLS: ToolDef[] = [ + { + name: "http_request", + tier: "connector", + connector: "http", + scope: "read", + description: + "Call any REST/HTTP(S) API by configuration — no bespoke code. Supply `method`, and either `url` or `base`(+`path`), plus optional `query`, `headers`, and JSON `body`; every string supports `{{param}}` interpolation from `inputs`. Optional `auth` injects a vault-stored API key into a header or query param (e.g. Google Places X-Goog-Api-Key). Optional `responseMap` extracts fields (dotted paths + `array[].{a,b:path}` projection). Optional `pagination` returns the next cursor/offset for the caller to fan out. Returns { status, data, raw? }. HTTPS-only, SSRF-guarded.", + jsonSchema: { + type: "object", + properties: { + method: { type: "string", description: "HTTP method (GET, POST, …). Default GET." }, + url: { type: "string", description: "Full request URL (or use base+path). Supports {{param}}." }, + base: { type: "string", description: "Base URL, joined with `path`. Supports {{param}}." }, + path: { type: "string", description: "Path appended to `base`. Supports {{param}}." }, + query: { type: "object", description: "Query params (values interpolated + URL-encoded)." }, + headers: { type: "object", description: "Request headers (values interpolated)." }, + body: { type: "object", description: "JSON request body (interpolated). Sent as application/json." }, + inputs: { type: "object", description: "Values bound to {{param}} placeholders across url/query/headers/body." }, + auth: { + type: "object", + description: + 'Credential injection. { "mode": "none" } or { "mode": "api-key", "key": { "in": "header"|"query", "name": "X-Goog-Api-Key" } } — the key value is read from the vault, never passed here.', + }, + responseMap: { + type: "string", + description: 'Dotted extraction, e.g. "places[].{id,name:displayName.text,site:websiteUri}" or "candidates.0.content".', + }, + pagination: { + type: "object", + description: + 'Descriptor { "type": "nextPageToken"|"offset"|"cursor", "path": "nextPageToken" } — the response location of the next-page marker, returned as `nextCursor` for the caller to drive.', + }, + includeRaw: { type: "boolean", description: "When true (and responseMap is set), also return the unmapped body as `raw`." }, + }, + required: [], + }, + handler: async (ctx: RegistryToolCtx, input) => { + const inputs = (input.inputs && typeof input.inputs === "object" && !Array.isArray(input.inputs) + ? input.inputs + : {}) as Record; + + let auth: HttpAuth; + let url: string; + try { + auth = parseAuth(input.auth); + url = buildUrl(input, inputs); + } catch (e) { + return { content: e instanceof Error ? e.message : String(e), success: false }; + } + + const method = (typeof input.method === "string" ? input.method : "GET").toUpperCase(); + const headers = new Headers(); + if (input.headers && typeof input.headers === "object" && !Array.isArray(input.headers)) { + for (const [k, v] of Object.entries(interpolateDeep(input.headers, inputs) as Record)) { + if (v !== undefined && v !== null) headers.set(k, String(v)); + } + } + + // Inject the vault API key into the configured header/query param. token() reads + // user_api_keys (provider "http") via the connectorClient — the value is used only + // on the wire, never returned or logged. + if (auth.mode === "api-key") { + const key = await ctx.connectorClient?.("http").token().catch(() => null); + if (!key) return { content: "No API key connected for the http connector — add one in the instance's Connections settings.", success: false }; + if (auth.key.in === "header") { + headers.set(auth.key.name, key); + } else { + const u = new URL(url); + u.searchParams.set(auth.key.name, key); + url = u.toString(); + } + } + + let bodyStr: string | undefined; + if (method !== "GET" && method !== "HEAD" && input.body !== undefined && input.body !== null) { + bodyStr = JSON.stringify(interpolateDeep(input.body, inputs)); + if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json"); + } + + let res: Response; + try { + res = await safeFetch(url, { method, headers, body: bodyStr }); + } catch (e) { + if (e instanceof SsrfError) return { content: `Blocked: ${e.message}`, success: false }; + return { content: `Request failed: ${e instanceof Error ? e.message : String(e)}`, success: false }; + } + + const text = await res.text(); + let raw: unknown = text; + try { + raw = JSON.parse(text); + } catch { + /* keep as text */ + } + + const responseMap = typeof input.responseMap === "string" ? input.responseMap : ""; + const data = responseMap ? applyResponseMap(raw, responseMap) : raw; + + const result: Record = { status: res.status, data }; + // pagination: surface the next-page marker so a source step can fan out. + if (input.pagination && typeof input.pagination === "object" && !Array.isArray(input.pagination)) { + const p = input.pagination as Record; + const path = typeof p.path === "string" ? p.path : ""; + const marker = path ? getPath(raw, path) : undefined; + result.nextCursor = marker ?? null; + result.paginationType = typeof p.type === "string" ? p.type : null; + } + if (responseMap && input.includeRaw === true) result.raw = raw; + + return { content: JSON.stringify(result, null, 2), success: res.ok }; + }, + }, +]; diff --git a/workers/api/src/lib/connectors/registry.test.ts b/workers/api/src/lib/connectors/registry.test.ts index 8e61bffd..433e5cfa 100644 --- a/workers/api/src/lib/connectors/registry.test.ts +++ b/workers/api/src/lib/connectors/registry.test.ts @@ -2,9 +2,18 @@ import { describe, expect, it } from "vitest"; import { CONNECTORS, connectorTools, getConnector } from "./registry.js"; describe("connector registry", () => { - it("declares github, meta, and tmux", () => { + it("declares github, http, meta, and tmux", () => { const ids = CONNECTORS.map((c) => c.id).sort(); - expect(ids).toEqual(["github", "meta", "tmux"]); + expect(ids).toEqual(["github", "http", "meta", "tmux"]); + }); + + it("http is a token-auth, read+write, user-grant connector with no tokenEnv (vault-backed)", () => { + const http = getConnector("http"); + expect(http?.auth).toBe("token"); + expect(http?.tokenEnv).toBeUndefined(); // no platform env → connectorClient reads the vault key + expect(http?.scopes).toEqual({ read: true, write: true }); + expect(http?.grantModel).toBe("user"); + expect(http?.tools.map((t) => t.name)).toEqual(["http_request"]); }); it("github is an app-auth, read+write, user-grant connector", () => { diff --git a/workers/api/src/lib/connectors/registry.ts b/workers/api/src/lib/connectors/registry.ts index deb8da97..7b78fa22 100644 --- a/workers/api/src/lib/connectors/registry.ts +++ b/workers/api/src/lib/connectors/registry.ts @@ -5,6 +5,7 @@ import type { Env } from "../../types.js"; import type { ToolDef } from "../tool-registry.js"; import { GITHUB_TOOLS } from "./github.js"; +import { HTTP_TOOLS } from "./http.js"; import { META_TOOLS } from "./meta.js"; import { TMUX_TOOLS } from "./tmux.js"; @@ -68,6 +69,17 @@ export const CONNECTORS: Connector[] = [ grantModel: "user", tools: TMUX_TOOLS, }, + { + id: "http", + label: "HTTP / REST (generic)", + // auth:"token", no tokenEnv → connectorClient.token() reads the user's vault key + // (user_api_keys, provider "http") for api-key mode; http_request injects it into + // the configured header/query param itself (not as a Bearer header). + auth: "token", + scopes: { read: true, write: true }, + grantModel: "user", + tools: HTTP_TOOLS, + }, ]; const BY_ID: ReadonlyMap = new Map(CONNECTORS.map((c) => [c.id, c] as const)); diff --git a/workers/api/src/routes/tools.test.ts b/workers/api/src/routes/tools.test.ts index eb310b3b..7b4f993e 100644 --- a/workers/api/src/routes/tools.test.ts +++ b/workers/api/src/routes/tools.test.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { HttpError } from "../lib/auth.js"; import { signSession } from "../lib/session.js"; import { toolRoutes } from "./tools.js"; @@ -116,4 +116,23 @@ describe("POST /v1/instances/:id/tools/:name", () => { const body = (await res.json()) as any; expect(body.content).toMatch(/not connected|not configured/i); }); + it("invokes the generic http_request tool through the SAME route — no bespoke route (issue #95)", async () => { + const { app, env } = testApp(); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ places: [{ id: "p1", displayName: { text: "Cafe" } }] }), { status: 200, headers: { "Content-Type": "application/json" } }), + ); + const res = await req( + app, + env, + "/v1/instances/i1/tools/http_request", + { method: "POST", body: JSON.stringify({ url: "https://places.googleapis.com/v1/x", responseMap: "places[].{id,name:displayName.text}" }) }, + await tok("u1"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(body.name).toBe("http_request"); + expect(body.success).toBe(true); + expect(JSON.parse(body.content).data).toEqual([{ id: "p1", name: "Cafe" }]); + fetchSpy.mockRestore(); + }); });