| 46 | |
| 47 | export class HttpRequestTool extends Tool<z.infer<typeof HttpRequestArgs>> { |
| 48 | name = 'http_request'; |
| 49 | description = 'Make an HTTP request to an external or local URL. For local dev APIs pass allow_local=true. Returns status, headers, and a head+tail excerpt (~6KB) of the response body with the total size noted — pass fullBody=true if you genuinely need the whole body (up to 1MB). Destructive when method is POST/PUT/PATCH/DELETE — those mutate remote state.'; |
| 50 | isReadOnly = false; // POST/PUT/DELETE mutate |
| 51 | isDestructive = true; |
| 52 | argsSchema = HttpRequestArgs; |
| 53 | |
| 54 | async execute(args: z.infer<typeof HttpRequestArgs>, ctx: ToolContext): Promise<ToolResult> { |
| 55 | const method = args.method ?? 'GET'; |
| 56 | const timeoutMs = (args.timeout_seconds ?? 30) * 1000; |
| 57 | const maxBytes = args.max_response_bytes ?? 1_048_576; |
| 58 | |
| 59 | // Build final URL with query string |
| 60 | let urlStr = args.url; |
| 61 | if (args.query) { |
| 62 | const u = new URL(urlStr); |
| 63 | for (const [k, v] of Object.entries(args.query)) u.searchParams.set(k, v); |
| 64 | urlStr = u.toString(); |
| 65 | } |
| 66 | |
| 67 | let parsed: URL; |
| 68 | try { parsed = new URL(urlStr); } catch { |
| 69 | return { content: `[HTTP_REQUEST_ERROR] Invalid URL: ${urlStr}`, isError: true }; |
| 70 | } |
| 71 | |
| 72 | if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { |
| 73 | return { content: `[HTTP_REQUEST_ERROR] Only http:/https: allowed. Got: ${parsed.protocol}`, isError: true }; |
| 74 | } |
| 75 | |
| 76 | if (PRIVATE_HOST_RE.test(parsed.hostname) && !args.allow_local) { |
| 77 | return { |
| 78 | content: `[HTTP_REQUEST_BLOCKED] Refusing to call private host ${parsed.hostname}. Pass allow_local=true if this is intentional (dev API testing).`, |
| 79 | isError: true, |
| 80 | }; |
| 81 | } |
| 82 | |
| 83 | const startTime = Date.now(); |
| 84 | const abort = new AbortController(); |
| 85 | const timer = setTimeout(() => abort.abort('timeout'), timeoutMs); |
| 86 | // Cascade outer signal |
| 87 | if (ctx.signal) { |
| 88 | if (ctx.signal.aborted) abort.abort('cancelled'); |
| 89 | else ctx.signal.addEventListener('abort', () => abort.abort('cancelled'), { once: true }); |
| 90 | } |
| 91 | |
| 92 | try { |
| 93 | const resp = await proxyFetch(urlStr, { |
| 94 | method, |
| 95 | headers: args.headers, |
| 96 | body: args.body, |
| 97 | signal: abort.signal, |
| 98 | redirect: args.follow_redirects === false ? 'manual' : 'follow', |
| 99 | }); |
| 100 | |
| 101 | // Pick a subset of headers to surface |
| 102 | const headersOut: Record<string, string> = {}; |
| 103 | const interesting = new Set(['content-type', 'content-length', 'content-encoding', 'cache-control', 'set-cookie', 'location', 'server', 'date', 'etag', 'last-modified', 'access-control-allow-origin', 'x-ratelimit-remaining', 'x-ratelimit-limit', 'x-request-id']); |
| 104 | resp.headers.forEach((v, k) => { if (interesting.has(k.toLowerCase())) headersOut[k] = v; }); |
| 105 | |