Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ import { TaskCreateTool } from './tools/TaskCreateTool/TaskCreateTool.js'
import { TaskGetTool } from './tools/TaskGetTool/TaskGetTool.js'
import { TaskUpdateTool } from './tools/TaskUpdateTool/TaskUpdateTool.js'
import { TaskListTool } from './tools/TaskListTool/TaskListTool.js'
import { HttpRequestTool } from './tools/HttpRequestTool/HttpRequestTool.js'
import { GraphqlTool } from './tools/GraphqlTool/GraphqlTool.js'
import { NetworkDiagnosticTool } from './tools/NetworkDiagnosticTool/NetworkDiagnosticTool.js'
import uniqBy from 'lodash-es/uniqBy.js'
import { isToolSearchEnabledOptimistic } from './utils/toolSearch.js'
import { isTodoV2Enabled } from './utils/tasks.js'
Expand Down Expand Up @@ -194,6 +197,9 @@ export function getAllBaseTools(): Tools {
FileWriteTool,
NotebookEditTool,
WebFetchTool,
HttpRequestTool,
GraphqlTool,
NetworkDiagnosticTool,
TodoWriteTool,
WebSearchTool,
TaskStopTool,
Expand Down
40 changes: 40 additions & 0 deletions src/tools/GraphqlTool/GraphqlTool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'bun:test'
import { GRAPHQL_TOOL_NAME } from './prompt.js'
import { GraphqlTool } from './GraphqlTool.js'

describe('GraphqlTool', () => {
it('has the correct name', () => { expect(GraphqlTool.name).toBe(GRAPHQL_TOOL_NAME) })
it('has a non-empty description', async () => { expect((await GraphqlTool.description()).length).toBeGreaterThan(0) })
it('has isEnabled default from buildTool', () => { expect(GraphqlTool.isEnabled()).toBe(true) })

it('marks query as read-only', () => { expect(GraphqlTool.isReadOnly?.({ query: 'query { users { id } }' })).toBe(true) })
it('marks mutation as not read-only', () => { expect(GraphqlTool.isReadOnly?.({ query: 'mutation { createUser(name: "x") { id } }' })).toBe(false) })

it('accepts valid input', async () => { expect((await GraphqlTool.validateInput({ endpoint: 'https://api.example.com/graphql', query: 'query { users { id } }' })).result).toBe(true) })
it('rejects missing endpoint', async () => { expect((await GraphqlTool.validateInput({} as any)).result).toBe(false) })

it('asks permission for mutation', async () => {
const p = await GraphqlTool.checkPermissions!({ endpoint: 'https://api.example.com/graphql', query: 'mutation { createUser(name: "x") { id } }' })
expect(p.behavior).toBe('ask')
})
it('allows query without permission', async () => {
const p = await GraphqlTool.checkPermissions!({ endpoint: 'https://api.example.com/graphql', query: 'query { users { id } }' })
expect(p.behavior).toBe('allow')
})

it('has mapToolResultToToolResultBlockParam', () => {
const b = GraphqlTool.mapToolResultToToolResultBlockParam({ success: true, durationMs: 50 }, 'tid')
expect(b.tool_use_id).toBe('tid'); expect(b.type).toBe('tool_result')
})
it('renders success', () => {
const m = GraphqlTool.renderToolResultMessage?.({ success: true, durationMs: 200 })
if (m && 'text' in m) expect(m.text).toContain('succeeded')
})
it('renders GraphQL errors', () => {
const m = GraphqlTool.renderToolResultMessage?.({ success: false, errors: [{ message: 'field not found' }], durationMs: 100 })
if (m && 'text' in m) expect(m.text).toContain('1 error(s)')
})
it('provides auto-classifier', () => {
expect(GraphqlTool.toAutoClassifierInput?.({ endpoint: 'https://api.example.com/graphql', query: 'query { users }' })).toContain('api.example.com')
})
})
86 changes: 86 additions & 0 deletions src/tools/GraphqlTool/GraphqlTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { z } from 'zod/v4'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { DESCRIPTION, GRAPHQL_TOOL_NAME, PROMPT } from './prompt.js'

const inputSchema = lazySchema(() =>
z.strictObject({
endpoint: z.string().url().describe('GraphQL endpoint URL'),
query: z.string().min(1).describe('GraphQL query or mutation'),
variables: z.record(z.unknown()).optional().describe('Query variables'),
operationName: z.string().optional().describe('Operation name'),
headers: z.record(z.string()).optional().describe('Custom headers'),
timeout: z.number().min(1).max(300).optional().default(30).describe('Request timeout in seconds'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>

const outputSchema = lazySchema(() =>
z.object({
success: z.boolean(),
data: z.unknown().optional(),
errors: z.array(z.object({ message: z.string(), locations: z.array(z.object({ line: z.number(), column: z.number() })).optional() })).optional(),
durationMs: z.number(),
error: z.string().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>

const MAX_RESPONSE_CHARS = 100_000

export const GraphqlTool = buildTool({
name: GRAPHQL_TOOL_NAME,
searchHint: 'execute GraphQL queries and mutations',
maxResultSizeChars: MAX_RESPONSE_CHARS,
strict: true,
get inputSchema(): InputSchema { return inputSchema() },
get outputSchema(): OutputSchema { return outputSchema() },
userFacingName: () => 'GraphQL Query',
isReadOnly(input) {
if (input && 'query' in input) return !String(input.query).trim().match(/^\s*(mutation|subscription)\s/i)
return true
},
isDestructive() { return false },
toAutoClassifierInput(input) { return `${input.endpoint}: ${input.query.slice(0, 80)}` },
async description() { return DESCRIPTION },
async prompt() { return PROMPT },
async validateInput(input) {
if (!input.endpoint) return { result: false, message: 'Missing required parameter: endpoint', errorCode: 1 }
if (!input.query) return { result: false, message: 'Missing required parameter: query', errorCode: 1 }
try { new URL(input.endpoint) } catch { return { result: false, message: 'Invalid endpoint URL', errorCode: 1 } }
return { result: true }
},
async checkPermissions(input) {
const isMut = String(input.query).trim().match(/^\s*(mutation|subscription)\s/i)
if (isMut) return { behavior: 'ask', askReason: `Send ${isMut[1].toUpperCase()} to ${input.endpoint}?`, updatedInput: input }
return { behavior: 'allow', updatedInput: input }
},
mapToolResultToToolResultBlockParam(output, toolUseID) {
return { tool_use_id: toolUseID, type: 'tool_result', content: JSON.stringify(output) }
},
renderToolUseMessage(input) {
const type = String(input.query).trim().match(/^\s*(query|mutation|subscription)/i)?.[1]?.toUpperCase() ?? 'QUERY'
return { type: 'text', text: `${type} ${input.endpoint}` }
},
renderToolResultMessage(output) {
if (!output.success && output.error) return { type: 'text', text: `GraphQL request failed: ${output.error}` }
if (output.errors?.length) return { type: 'text', text: `GraphQL returned ${output.errors.length} error(s) in ${output.durationMs}ms` }
return { type: 'text', text: `GraphQL query succeeded in ${output.durationMs}ms` }
},
async call(input, _ctx, _canUseTool?, _parentMessage?, _onProgress?) {
const startTime = Date.now()
try {
const body: Record<string, unknown> = { query: input.query }
if (input.variables) body.variables = input.variables
if (input.operationName) body.operationName = input.operationName
const resp = await fetch(input.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...input.headers }, body: JSON.stringify(body), signal: AbortSignal.timeout((input.timeout ?? 30) * 1000) })
const text = await resp.text()
if (text.length > MAX_RESPONSE_CHARS) return { data: { success: false, durationMs: Date.now() - startTime, error: 'Response too large' } }
const parsed = JSON.parse(text) as { data?: unknown; errors?: Array<{ message: string; locations?: Array<{ line: number; column: number }> }> }
return { data: { success: !(parsed.errors?.length), data: parsed.data, errors: parsed.errors, durationMs: Date.now() - startTime } }
} catch (err) {
return { data: { success: false, durationMs: Date.now() - startTime, error: err instanceof Error ? err.message : String(err) } }
}
},
})
15 changes: 15 additions & 0 deletions src/tools/GraphqlTool/prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const GRAPHQL_TOOL_NAME = 'GraphqlQuery'
export const DESCRIPTION = 'Execute GraphQL queries and mutations against GraphQL endpoints. Supports variables, headers, and schema introspection.'
export const PROMPT = `Execute GraphQL queries and mutations.

## Usage
- Specify endpoint URL and GraphQL query
- Optional variables as key-value pairs
- Optional custom headers for authentication
- Supports introspection queries

## Safety
- Read-only by default (queries only for introspection)
- Timeout prevents hanging (default: 30s)
- Response size limited
`
49 changes: 49 additions & 0 deletions src/tools/HttpRequestTool/HttpRequestTool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'bun:test'
import { HTTP_REQUEST_TOOL_NAME } from './prompt.js'
import { HttpRequestTool } from './HttpRequestTool.js'

describe('HttpRequestTool', () => {
it('has the correct name', () => { expect(HttpRequestTool.name).toBe(HTTP_REQUEST_TOOL_NAME) })
it('has a non-empty description', async () => { expect((await HttpRequestTool.description()).length).toBeGreaterThan(0) })
it('has checkPermissions default from buildTool', () => { expect(typeof HttpRequestTool.checkPermissions).toBe('function') })
it('has isEnabled default from buildTool', () => { expect(HttpRequestTool.isEnabled()).toBe(true) })

it('marks GET as read-only', () => { expect(HttpRequestTool.isReadOnly?.({ method: 'GET' })).toBe(true) })
it('marks HEAD as read-only', () => { expect(HttpRequestTool.isReadOnly?.({ method: 'HEAD' })).toBe(true) })
it('marks POST as not read-only', () => { expect(HttpRequestTool.isReadOnly?.({ method: 'POST' })).toBe(false) })
it('marks PUT as not read-only', () => { expect(HttpRequestTool.isReadOnly?.({ method: 'PUT' })).toBe(false) })
it('marks DELETE as not read-only', () => { expect(HttpRequestTool.isReadOnly?.({ method: 'DELETE' })).toBe(false) })

it('accepts valid URL', async () => { expect((await HttpRequestTool.validateInput({ url: 'https://api.example.com/data' })).result).toBe(true) })
it('rejects missing URL', async () => { expect((await HttpRequestTool.validateInput({} as any)).result).toBe(false) })
it('rejects invalid URL', async () => { expect((await HttpRequestTool.validateInput({ url: 'not-a-url' })).result).toBe(false) })

it('asks permission for POST', async () => {
const p = await HttpRequestTool.checkPermissions!({ method: 'POST', url: 'https://api.example.com/data' })
expect(p.behavior).toBe('ask')
})
it('allows GET without permission', async () => {
const p = await HttpRequestTool.checkPermissions!({ method: 'GET', url: 'https://api.example.com/data' })
expect(p.behavior).toBe('allow')
})

it('has mapToolResultToToolResultBlockParam', () => {
const b = HttpRequestTool.mapToolResultToToolResultBlockParam({ success: true, durationMs: 100 }, 'tid')
expect(b.tool_use_id).toBe('tid'); expect(b.type).toBe('tool_result')
})
it('renders tool use message', () => {
const m = HttpRequestTool.renderToolUseMessage?.({ url: 'https://api.example.com' })
if (m && 'text' in m) expect(m.text).toContain('api.example.com')
})
it('renders success 2xx', () => {
const m = HttpRequestTool.renderToolResultMessage?.({ success: true, response: { status: 200, statusText: 'OK', headers: {}, body: '{}' }, durationMs: 150 })
if (m && 'text' in m) expect(m.text).toContain('200')
})
it('renders failed request', () => {
const m = HttpRequestTool.renderToolResultMessage?.({ success: false, durationMs: 5, error: 'connection refused' })
if (m && 'text' in m) expect(m.text).toContain('connection refused')
})
it('provides auto-classifier input', () => {
expect(HttpRequestTool.toAutoClassifierInput?.({ method: 'GET', url: 'https://api.example.com' })).toBe('GET https://api.example.com')
})
})
91 changes: 91 additions & 0 deletions src/tools/HttpRequestTool/HttpRequestTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { z } from 'zod/v4'
import { buildTool, type ToolDef } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { DESCRIPTION, HTTP_REQUEST_TOOL_NAME, PROMPT } from './prompt.js'

const inputSchema = lazySchema(() =>
z.strictObject({
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']).default('GET').describe('HTTP method'),
url: z.string().url().describe('Request URL'),
headers: z.record(z.string()).optional().describe('Request headers'),
query: z.record(z.string()).optional().describe('Query parameters'),
body: z.union([z.string(), z.record(z.unknown()), z.array(z.unknown())]).optional().describe('Request body'),
timeout: z.number().min(1).max(300).optional().default(30).describe('Request timeout in seconds'),
followRedirects: z.boolean().optional().default(true).describe('Follow redirects'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>

const outputSchema = lazySchema(() =>
z.object({
success: z.boolean(),
response: z.object({ status: z.number(), statusText: z.string(), headers: z.record(z.string()), body: z.string(), bodyTruncated: z.boolean().optional() }).optional(),
redirectChain: z.array(z.string()).optional(),
durationMs: z.number(),
error: z.string().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>

const MAX_BODY_CHARS = 50_000
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])

export const HttpRequestTool = buildTool({
name: HTTP_REQUEST_TOOL_NAME,
searchHint: 'make HTTP requests to test REST APIs',
maxResultSizeChars: MAX_BODY_CHARS,
strict: true,
get inputSchema(): InputSchema { return inputSchema() },
get outputSchema(): OutputSchema { return outputSchema() },
userFacingName: () => 'HTTP Request',
isReadOnly(input) { return input ? SAFE_METHODS.has(input.method) : true },
isDestructive() { return false },
toAutoClassifierInput(input) { return `${input.method} ${input.url}` },
async description() { return DESCRIPTION },
async prompt() { return PROMPT },
async validateInput(input) {
if (!input.url) return { result: false, message: 'Missing required parameter: url', errorCode: 1 }
try { new URL(input.url) } catch { return { result: false, message: 'Invalid URL format', errorCode: 1 } }
return { result: true }
},
async checkPermissions(input) {
if (!SAFE_METHODS.has(input.method)) return { behavior: 'ask', askReason: `Send ${input.method} to ${input.url}?`, updatedInput: input }
return { behavior: 'allow', updatedInput: input }
},
mapToolResultToToolResultBlockParam(output, toolUseID) {
return { tool_use_id: toolUseID, type: 'tool_result', content: JSON.stringify(output) }
},
renderToolUseMessage(input) {
return { type: 'text', text: `${input.method ?? 'GET'} ${input.url}` }
},
renderToolResultMessage(output) {
if (!output.success) return { type: 'text', text: `Request failed: ${output.error}` }
if (!output.response) return { type: 'text', text: 'No response received' }
const icon = output.response.status >= 200 && output.response.status < 300 ? '✅' : output.response.status >= 400 ? '❌' : '⚠️'
return { type: 'text', text: `${icon} ${output.response.status} ${output.response.statusText} (${output.durationMs}ms)` }
},
async call(input, _ctx, _canUseTool?, _parentMessage?, _onProgress?) {
const startTime = Date.now()
try {
const urlObj = new URL(input.url)
if (input.query) for (const [k, v] of Object.entries(input.query)) urlObj.searchParams.set(k, v)
const headers: Record<string, string> = { ...input.headers }
const fetchOpts: RequestInit = { method: input.method, headers, redirect: input.followRedirects ? 'follow' : 'manual', signal: AbortSignal.timeout((input.timeout ?? 30) * 1000) }
if (input.body) {
const bodyStr = typeof input.body === 'string' ? input.body : JSON.stringify(input.body)
if (!Object.keys(headers).some(k => k.toLowerCase() === 'content-type')) headers['Content-Type'] = typeof input.body === 'string' && !(input.body as string).startsWith('{') && !(input.body as string).startsWith('[') ? 'text/plain' : 'application/json'
fetchOpts.body = bodyStr
}
const resp = await fetch(urlObj.toString(), fetchOpts)
let bodyText = await resp.text()
const bodyTruncated = bodyText.length > MAX_BODY_CHARS
if (bodyTruncated) bodyText = bodyText.slice(0, MAX_BODY_CHARS)
const respHeaders: Record<string, string> = {}
resp.headers.forEach((v, k) => { respHeaders[k] = v })
return { data: { success: true, response: { status: resp.status, statusText: resp.statusText, headers: respHeaders, body: bodyText, bodyTruncated }, durationMs: Date.now() - startTime } }
} catch (err) {
return { data: { success: false, durationMs: Date.now() - startTime, error: err instanceof Error ? err.message : String(err) } }
}
},
})
25 changes: 25 additions & 0 deletions src/tools/HttpRequestTool/prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const HTTP_REQUEST_TOOL_NAME = 'HttpRequest'
export const DESCRIPTION = 'Make HTTP requests to test and debug REST APIs. Supports GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS with custom headers, query params, and request body.'
export const PROMPT = `Make HTTP requests to test and debug REST APIs.

## Usage
- Supports all common HTTP methods
- Automatic JSON body serialization
- Custom headers, query parameters, and timeouts
- Follows redirects by default with configurable limit

## Methods
- GET: Retrieve resources
- POST: Create resources
- PUT: Update resources
- PATCH: Partial updates
- DELETE: Remove resources
- HEAD: Headers only
- OPTIONS: Available methods

## Safety
- Timeout prevents hanging requests (default: 30s)
- Response size limited to prevent memory issues
- Redirects followed but configurable
- No automatic credential forwarding
`
Loading
Loading