Unified · Split
| @@ -29,7 +29,8 @@ import type { TaskEvent } from "@openassistant/core/events"; · 28 unmodified lines | ||
| 29 | 29 | interface PendingApproval { |
| 30 | 30 | readonly id: string; |
| 31 | 31 | readonly toolPath: string; |
| 32 | − readonly preview: { title: string; details?: string }; | |
| 32 | + readonly input: unknown; | |
| 33 | + readonly preview: { title: string; details?: string; link?: string }; | |
| 33 | 34 | } |
| 34 | 35 | |
| 35 | 36 | interface TaskState { |
| @@ -65,7 +66,7 @@ function reduceEvent(state: TaskState, event: TaskEvent): TaskState { · 29 unmodified lines | ||
| 65 | 66 | ...state, |
| 66 | 67 | pendingApprovals: [ |
| 67 | 68 | ...state.pendingApprovals, |
| 68 | − { id: event.id, toolPath: event.toolPath, preview: event.preview }, | |
| 69 | + { id: event.id, toolPath: event.toolPath, input: event.input, preview: event.preview }, | |
| 69 | 70 | ], |
| 70 | 71 | statusMessage: "Waiting for approval...", |
| 71 | 72 | }; |
| @@ -214,6 +215,7 @@ export function TaskMessage({ taskId, prompt, api }: TaskMessageProps) { · 142 unmodified lines | ||
| 214 | 215 | |
| 215 | 216 | function ApprovalButtons({ approval, api }: { approval: PendingApproval; api: ApiClient }) { |
| 216 | 217 | const [resolved, setResolved] = useState(false); |
| 218 | + const argsPreview = formatApprovalInput(approval.input); | |
| 217 | 219 | |
| 218 | 220 | const handle = async (decision: "approved" | "denied") => { |
| 219 | 221 | setResolved(true); |
| @@ -228,8 +230,9 @@ function ApprovalButtons({ approval, api }: { approval: PendingApproval; api: Ap · 8 unmodified lines | ||
| 228 | 230 | <> |
| 229 | 231 | <Separator /> |
| 230 | 232 | <TextDisplay> |
| 231 | − {`\u{1f6e1}\ufe0f **Approval required:** ${approval.preview.title}${approval.preview.details ? `\n${approval.preview.details}` : ""}`} | |
| 233 | + {`\u{1f6e1}\ufe0f **Approval required:** ${approval.preview.title}${approval.preview.details ? `\n${approval.preview.details}` : ""}${approval.preview.link ? `\n${approval.preview.link}` : ""}`} | |
| 232 | 234 | </TextDisplay> |
| 235 | + <TextDisplay>{`\`\`\`json\n${argsPreview}\n\`\`\``}</TextDisplay> | |
| 233 | 236 | {!resolved && ( |
| 234 | 237 | <ActionRow> |
| 235 | 238 | <Button label="Approve" style="success" onClick={() => handle("approved")} /> |
| @@ -252,3 +255,15 @@ function statusEmoji(status: TaskState["status"]): string { · 16 unmodified lines | ||
| 252 | 255 | case "cancelled": return "\u26d4"; |
| 253 | 256 | } |
| 254 | 257 | } |
| 258 | + | |
| 259 | +function formatApprovalInput(input: unknown): string { | |
| 260 | + if (input === undefined) return "undefined"; | |
| 261 | + if (input === null) return "null"; | |
| 262 | + try { | |
| 263 | + const serialized = JSON.stringify(input, null, 2); | |
| 264 | + if (!serialized) return String(input); | |
| 265 | + return serialized.length > 1000 ? `${serialized.slice(0, 1000)}...` : serialized; | |
| 266 | + } catch { | |
| 267 | + return String(input); | |
| 268 | + } | |
| 269 | +} | |
| @@ -186,19 +186,31 @@ function buildSystemPrompt(guidance: string, discoveryMode: boolean): string { · 185 unmodified lines | ||
| 186 | 186 | ? ` |
| 187 | 187 | ## Tool Discovery |
| 188 | 188 | |
| 189 | −Some tool namespaces are too large to list here. Use \`tools.discover({ query: "..." })\` to search for tools by keyword. | |
| 190 | −It returns matching tool paths, descriptions, and TypeScript signatures. | |
| 189 | +Some tool namespaces are too large to list here. Use \`tools.discover({ query, depth? })\` to search for tools by keyword. | |
| 191 | 190 | |
| 192 | −**Workflow:** First call discover to learn what tools are available. Then write a SINGLE self-contained script that does the entire task — fetching data, looping, transforming, and writing back. Don't split the actual work across multiple run_code calls. | |
| 191 | +The \`depth\` parameter controls how much type detail you get: | |
| 192 | +- **depth 0** (default): tool paths, descriptions, and input arg types only. Fast, use for browsing. | |
| 193 | +- **depth 1**: adds return types (comments stripped). Use when you need to know response shapes before writing code. | |
| 194 | +- **depth 2**: full signatures with JSDoc comments and examples. Use when you need exact details. | |
| 195 | + | |
| 196 | +**Workflow:** | |
| 197 | +1. Discover tools at depth 0 to find what's available | |
| 198 | +2. If you need to know response shapes, re-discover at depth 1 for the specific tools you'll use | |
| 199 | +3. Write a SINGLE self-contained script that does the entire task | |
| 193 | 200 | |
| 194 | 201 | Example — "close all open issues on acme/myapp": |
| 195 | 202 | |
| 196 | −Call 1 (discovery): | |
| 203 | +Call 1 (find tools): | |
| 197 | 204 | \`\`\`ts |
| 198 | 205 | return await tools.discover({ query: "issues list update repo" }); |
| 199 | 206 | \`\`\` |
| 200 | 207 | |
| 201 | −Call 2 (do the work — everything in one script): | |
| 208 | +Call 2 (get return types for the tools you'll use): | |
| 209 | +\`\`\`ts | |
| 210 | +return await tools.discover({ query: "issues list_for_repo update", depth: 1 }); | |
| 211 | +\`\`\` | |
| 212 | + | |
| 213 | +Call 3 (do the work — everything in one script): | |
| 202 | 214 | \`\`\`ts |
| 203 | 215 | const issues = await tools.github.issues.issues_list_for_repo({ |
| 204 | 216 | owner: "acme", repo: "myapp", state: "open", per_page: 100 |
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Tool discovery — searchable index of tools for large APIs. |
| 3 | 3 | * |
| 4 | − * When there are too many tools to fit in the LLM context window, | |
| 5 | − * the agent uses `tools.discover({ query })` to search for relevant | |
| 6 | − * tools by name, path, and description. Returns type signatures so | |
| 7 | − * the LLM knows how to call what it finds. | |
| 4 | + * The LLM uses `tools.discover({ query, depth? })` to find tools and | |
| 5 | + * control how much type detail to retrieve: | |
| 6 | + * | |
| 7 | + * - depth 0 (default): paths, descriptions, input args only. Cheap in tokens. | |
| 8 | + * - depth 1: adds return types with top-level properties. | |
| 9 | + * - depth 2: full return types with nested objects expanded. | |
| 8 | 10 | * |
| 9 | 11 | * The discovered tools are already wired in the sandbox — the LLM |
| 10 | 12 | * just doesn't get told about all of them in the system prompt. |
| @@ -26,8 +28,10 @@ export interface ToolIndexEntry { · 15 unmodified lines | ||
| 26 | 28 | readonly description: string; |
| 27 | 29 | /** Approval mode */ |
| 28 | 30 | readonly approval: "auto" | "required"; |
| 29 | − /** TypeScript signature: "(input: { ... }): Promise<{ ... }>" */ | |
| 30 | − readonly signature: string; | |
| 31 | + /** Full args type string (always available) */ | |
| 32 | + readonly argsType: string; | |
| 33 | + /** Full returns type string (may be large for OpenAPI tools) */ | |
| 34 | + readonly returnsType: string; | |
| 31 | 35 | /** Searchable text (lowercase): path + description */ |
| 32 | 36 | readonly searchText: string; |
| 33 | 37 | } |
| @@ -44,13 +48,12 @@ export function buildToolIndex(tree: ToolTree): ToolIndexEntry[] { · 10 unmodified lines | ||
| 44 | 48 | const path = prefix ? `${prefix}.${key}` : key; |
| 45 | 49 | if (isToolDefinition(value)) { |
| 46 | 50 | const tool = value as ToolDefinition; |
| 47 | − const argsType = getArgsTypeString(tool); | |
| 48 | − const returnsType = getReturnsTypeString(tool); | |
| 49 | 51 | entries.push({ |
| 50 | 52 | path, |
| 51 | 53 | description: tool.description, |
| 52 | 54 | approval: tool.approval, |
| 53 | − signature: `(input: ${argsType}): Promise<${returnsType}>`, | |
| 55 | + argsType: getArgsTypeString(tool), | |
| 56 | + returnsType: getReturnsTypeString(tool), | |
| 54 | 57 | searchText: `${path} ${tool.description}`.toLowerCase(), |
| 55 | 58 | }); |
| 56 | 59 | } else { |
| @@ -63,6 +66,38 @@ export function buildToolIndex(tree: ToolTree): ToolIndexEntry[] { · 6 unmodified lines | ||
| 63 | 66 | return entries; |
| 64 | 67 | } |
| 65 | 68 | |
| 69 | +// --------------------------------------------------------------------------- | |
| 70 | +// Strip JSDoc comments from type strings | |
| 71 | +// --------------------------------------------------------------------------- | |
| 72 | + | |
| 73 | +function stripJSDoc(typeStr: string): string { | |
| 74 | + return typeStr | |
| 75 | + .replace(/\/\*\*[^]*?\*\//g, "") // block comments | |
| 76 | + .replace(/\n\s*\n/g, "\n") // collapse blank lines | |
| 77 | + .trim(); | |
| 78 | +} | |
| 79 | + | |
| 80 | +// --------------------------------------------------------------------------- | |
| 81 | +// Format signature at different depths | |
| 82 | +// --------------------------------------------------------------------------- | |
| 83 | + | |
| 84 | +/** | |
| 85 | + * Build a signature string for a tool at a given depth: | |
| 86 | + * - depth 0: input args only, no return type | |
| 87 | + * - depth 1: input args + return type (JSDoc stripped for conciseness) | |
| 88 | + * - depth 2: input args + full return type (with JSDoc) | |
| 89 | + */ | |
| 90 | +function formatSignature(entry: ToolIndexEntry, depth: number): string { | |
| 91 | + const args = depth >= 2 ? entry.argsType : stripJSDoc(entry.argsType); | |
| 92 | + | |
| 93 | + if (depth === 0) { | |
| 94 | + return `(input: ${args}): Promise<...>`; | |
| 95 | + } | |
| 96 | + | |
| 97 | + const returns = depth >= 2 ? entry.returnsType : stripJSDoc(entry.returnsType); | |
| 98 | + return `(input: ${args}): Promise<${returns}>`; | |
| 99 | +} | |
| 100 | + | |
| 66 | 101 | // --------------------------------------------------------------------------- |
| 67 | 102 | // Search |
| 68 | 103 | // --------------------------------------------------------------------------- |
| @@ -128,9 +163,17 @@ export interface DiscoverToolOptions { · 59 unmodified lines | ||
| 128 | 163 | * Create a `discover` tool that searches the tool index. |
| 129 | 164 | * |
| 130 | 165 | * Usage in LLM-generated code: |
| 166 | + * // Step 1: find tools (cheap — no return types) | |
| 131 | 167 | * const results = await tools.discover({ query: "feature flags" }); |
| 132 | − * // results is an array of { path, description, approval, signature } | |
| 133 | − * // Then call: await tools.posthog.feature_flags.list({ ... }) | |
| 168 | + * | |
| 169 | + * // Step 2: get full types for the tools you'll use | |
| 170 | + * const details = await tools.discover({ | |
| 171 | + * query: "feature flags list", | |
| 172 | + * depth: 1 | |
| 173 | + * }); | |
| 174 | + * | |
| 175 | + * // Step 3: call the tool | |
| 176 | + * await tools.posthog.feature_flags.list({ ... }) | |
| 134 | 177 | */ |
| 135 | 178 | export function createDiscoverTool( |
| 136 | 179 | tree: ToolTree, |
| @@ -140,10 +183,16 @@ export function createDiscoverTool( · 3 unmodified lines | ||
| 140 | 183 | const maxResults = options?.maxResults ?? 20; |
| 141 | 184 | |
| 142 | 185 | return defineTool({ |
| 143 | − description: `Search for available tools by keyword. Returns matching tool paths, descriptions, and TypeScript signatures. Use this when you need to find tools for a specific task. There are ${index.length} tools available across all connected services.`, | |
| 186 | + description: `Search for available tools by keyword. Returns matching tool paths, descriptions, and type signatures. There are ${index.length} tools available. | |
| 187 | + | |
| 188 | +depth controls how much type detail to return: | |
| 189 | +- depth 0 (default): input args only, return type hidden. Fast, cheap in tokens. Use for browsing. | |
| 190 | +- depth 1: input args + return types (comments stripped). Use when you need to know response shapes. | |
| 191 | +- depth 2: full signatures with JSDoc comments and examples. Use when you need exact details.`, | |
| 144 | 192 | approval: "auto" as const, |
| 145 | 193 | args: z.object({ |
| 146 | − query: z.string().describe("Search keywords (e.g. 'feature flags', 'create user', 'analytics events')"), | |
| 194 | + query: z.string().describe("Search keywords (e.g. 'feature flags', 'create user', 'list issues')"), | |
| 195 | + depth: z.number().optional().describe("Type detail level: 0 (default) = args only, 1 = + return types, 2 = + JSDoc comments"), | |
| 147 | 196 | }), |
| 148 | 197 | returns: z.object({ |
| 149 | 198 | results: z.array(z.object({ |
| @@ -155,13 +204,14 @@ export function createDiscoverTool( · 5 unmodified lines | ||
| 155 | 204 | total: z.number(), |
| 156 | 205 | }), |
| 157 | 206 | run: async (input) => { |
| 207 | + const depth = input.depth ?? 0; | |
| 158 | 208 | const results = searchIndex(index, input.query, maxResults); |
| 159 | 209 | return { |
| 160 | 210 | results: results.map((e) => ({ |
| 161 | 211 | path: e.path, |
| 162 | 212 | description: e.description, |
| 163 | 213 | approval: e.approval, |
| 164 | − signature: e.signature, | |
| 214 | + signature: formatSignature(e, depth), | |
| 165 | 215 | })), |
| 166 | 216 | total: results.length, |
| 167 | 217 | }; |
| @@ -329,6 +329,49 @@ describe("runner — approval flow", () => { · 328 unmodified lines | ||
| 329 | 329 | const req = capturedRequest as { preview: { title: string } }; |
| 330 | 330 | expect(req.preview.title).toBe("Delete user_42 from users"); |
| 331 | 331 | }); |
| 332 | + | |
| 333 | + test("approval request falls back to default preview when formatApproval is missing", async () => { | |
| 334 | + let capturedRequest: unknown; | |
| 335 | + const tools: ToolTree = { | |
| 336 | + vercel: { | |
| 337 | + projects: { | |
| 338 | + deleteProject: defineTool({ | |
| 339 | + description: "Delete a project", | |
| 340 | + approval: "required", | |
| 341 | + args: z.object({ idOrName: z.string() }), | |
| 342 | + returns: z.object({ ok: z.boolean() }), | |
| 343 | + run: async () => ({ ok: true }), | |
| 344 | + }), | |
| 345 | + }, | |
| 346 | + }, | |
| 347 | + }; | |
| 348 | + | |
| 349 | + const runner = createRunner({ | |
| 350 | + tools, | |
| 351 | + requestApproval: async (req) => { | |
| 352 | + capturedRequest = req; | |
| 353 | + return "denied"; | |
| 354 | + }, | |
| 355 | + newCallId: testCallId, | |
| 356 | + }); | |
| 357 | + | |
| 358 | + await runner.run('await tools.vercel.projects.deleteProject({ idOrName: "prj_123" })'); | |
| 359 | + | |
| 360 | + const req = capturedRequest as { | |
| 361 | + preview: { | |
| 362 | + title: string; | |
| 363 | + details?: string; | |
| 364 | + action?: string; | |
| 365 | + resourceIds?: string[]; | |
| 366 | + isDestructive?: boolean; | |
| 367 | + }; | |
| 368 | + }; | |
| 369 | + expect(req.preview.title).toBe("Delete via vercel.projects.deleteProject"); | |
| 370 | + expect(req.preview.details).toContain("Target: prj_123"); | |
| 371 | + expect(req.preview.action).toBe("delete"); | |
| 372 | + expect(req.preview.resourceIds).toEqual(["prj_123"]); | |
| 373 | + expect(req.preview.isDestructive).toBe(true); | |
| 374 | + }); | |
| 332 | 375 | }); |
| 333 | 376 | |
| 334 | 377 | describe("runner — error handling", () => { |
| @@ -53,6 +53,7 @@ export interface Runner { · 52 unmodified lines | ||
| 53 | 53 | // --------------------------------------------------------------------------- |
| 54 | 54 | |
| 55 | 55 | const MAX_PREVIEW = 180; |
| 56 | +const MAX_APPROVAL_DETAILS = 500; | |
| 56 | 57 | |
| 57 | 58 | function preview(value: unknown): string { |
| 58 | 59 | if (value === undefined) return "undefined"; |
| @@ -65,6 +66,96 @@ function preview(value: unknown): string { · 6 unmodified lines | ||
| 65 | 66 | } |
| 66 | 67 | } |
| 67 | 68 | |
| 69 | +function isRecord(value: unknown): value is Record<string, unknown> { | |
| 70 | + return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 71 | +} | |
| 72 | + | |
| 73 | +function inferApprovalAction(toolPath: string): { | |
| 74 | + action: NonNullable<ApprovalPresentation["action"]>; | |
| 75 | + isDestructive: boolean; | |
| 76 | +} { | |
| 77 | + const lower = toolPath.toLowerCase(); | |
| 78 | + if (/(delete|remove|destroy|purge)/.test(lower)) { | |
| 79 | + return { action: "delete", isDestructive: true }; | |
| 80 | + } | |
| 81 | + if (/(create|add|insert|provision)/.test(lower)) { | |
| 82 | + return { action: "create", isDestructive: false }; | |
| 83 | + } | |
| 84 | + if (/(update|set|patch|edit|rename)/.test(lower)) { | |
| 85 | + return { action: "update", isDestructive: false }; | |
| 86 | + } | |
| 87 | + if (/(get|list|search|find|read)/.test(lower)) { | |
| 88 | + return { action: "read", isDestructive: false }; | |
| 89 | + } | |
| 90 | + return { action: "execute", isDestructive: false }; | |
| 91 | +} | |
| 92 | + | |
| 93 | +function inferResourceType(toolPath: string): string | undefined { | |
| 94 | + const parts = toolPath.split(".").filter(Boolean); | |
| 95 | + const candidate = parts.at(-2) ?? parts.at(-1); | |
| 96 | + if (!candidate) return undefined; | |
| 97 | + return candidate | |
| 98 | + .replace(/_/g, " ") | |
| 99 | + .replace(/\b\w/g, (m) => m.toUpperCase()); | |
| 100 | +} | |
| 101 | + | |
| 102 | +function extractResourceIds(input: unknown): string[] { | |
| 103 | + if (!isRecord(input)) return []; | |
| 104 | + const ids: string[] = []; | |
| 105 | + const candidateKeys = ["id", "ids", "name", "slug", "key", "idOrName"]; | |
| 106 | + for (const key of candidateKeys) { | |
| 107 | + const value = input[key]; | |
| 108 | + if (typeof value === "string" || typeof value === "number") { | |
| 109 | + ids.push(String(value)); | |
| 110 | + continue; | |
| 111 | + } | |
| 112 | + if (Array.isArray(value)) { | |
| 113 | + for (const item of value) { | |
| 114 | + if (typeof item === "string" || typeof item === "number") { | |
| 115 | + ids.push(String(item)); | |
| 116 | + } | |
| 117 | + } | |
| 118 | + } | |
| 119 | + } | |
| 120 | + return ids.slice(0, 5); | |
| 121 | +} | |
| 122 | + | |
| 123 | +function extractCount(input: unknown): number | undefined { | |
| 124 | + if (!isRecord(input)) return undefined; | |
| 125 | + const raw = input["count"]; | |
| 126 | + return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined; | |
| 127 | +} | |
| 128 | + | |
| 129 | +function buildDefaultApprovalPresentation( | |
| 130 | + toolPath: string, | |
| 131 | + input: unknown, | |
| 132 | +): ApprovalPresentation { | |
| 133 | + const { action, isDestructive } = inferApprovalAction(toolPath); | |
| 134 | + const actionVerb = action === "execute" | |
| 135 | + ? "Run" | |
| 136 | + : action.charAt(0).toUpperCase() + action.slice(1); | |
| 137 | + const resourceType = inferResourceType(toolPath); | |
| 138 | + const resourceIds = extractResourceIds(input); | |
| 139 | + const count = extractCount(input); | |
| 140 | + const rawPreview = preview(input); | |
| 141 | + | |
| 142 | + const idDetail = resourceIds.length > 0 ? `Target: ${resourceIds.join(", ")}` : undefined; | |
| 143 | + const argsDetail = `Arguments: ${rawPreview}`; | |
| 144 | + const details = [idDetail, argsDetail].filter(Boolean).join("\n"); | |
| 145 | + | |
| 146 | + return { | |
| 147 | + title: `${actionVerb} via ${toolPath}`, | |
| 148 | + details: details.length > MAX_APPROVAL_DETAILS | |
| 149 | + ? `${details.slice(0, MAX_APPROVAL_DETAILS)}...` | |
| 150 | + : details, | |
| 151 | + action, | |
| 152 | + resourceType, | |
| 153 | + resourceIds: resourceIds.length > 0 ? resourceIds : undefined, | |
| 154 | + count, | |
| 155 | + isDestructive, | |
| 156 | + }; | |
| 157 | +} | |
| 158 | + | |
| 68 | 159 | function describeError(error: unknown): string { |
| 69 | 160 | if (error instanceof Error) { |
| 70 | 161 | // Include cause if present (e.g. nested API errors) |
| @@ -177,7 +268,7 @@ function createToolInvoker( · 106 unmodified lines | ||
| 177 | 268 | if (tool.approval === "required") { |
| 178 | 269 | const presentation: ApprovalPresentation = tool.formatApproval |
| 179 | 270 | ? tool.formatApproval(validatedInput) |
| 180 | − : { title: `Approve ${toolPath}` }; | |
| 271 | + : buildDefaultApprovalPresentation(toolPath, validatedInput); | |
| 181 | 272 | |
| 182 | 273 | const decision = await ctx.requestApproval({ |
| 183 | 274 | callId, |
| @@ -27,6 +27,16 @@ export interface ApprovalPresentation { · 26 unmodified lines | ||
| 27 | 27 | title: string; |
| 28 | 28 | details?: string; |
| 29 | 29 | link?: string; |
| 30 | + /** | |
| 31 | + * Optional structured metadata for policy and richer UIs. | |
| 32 | + * These fields are additive and backward-compatible. | |
| 33 | + */ | |
| 34 | + action?: "create" | "update" | "delete" | "read" | "execute" | "other"; | |
| 35 | + resourceType?: string; | |
| 36 | + resourceIds?: string[]; | |
| 37 | + count?: number; | |
| 38 | + isDestructive?: boolean; | |
| 39 | + selector?: string; | |
| 30 | 40 | } |
| 31 | 41 | |
| 32 | 42 | /** |
| @@ -30,6 +30,7 @@ describe("generateMcpTools — AnswerOverflow MCP", () => { · 29 unmodified lines | ||
| 30 | 30 | expect(tool.description).toBeDefined(); |
| 31 | 31 | expect(tool.description.length).toBeGreaterThan(0); |
| 32 | 32 | expect(tool.approval).toBe("auto"); // default |
| 33 | + expect(typeof tool.formatApproval).toBe("function"); | |
| 33 | 34 | }); |
| 34 | 35 | |
| 35 | 36 | // TypeScript declarations should be generated |
| @@ -91,4 +92,30 @@ describe("generateMcpTools — AnswerOverflow MCP", () => { · 55 unmodified lines | ||
| 91 | 92 | await result.close(); |
| 92 | 93 | } |
| 93 | 94 | }, { timeout: 30_000 }); |
| 95 | + | |
| 96 | + test("generated tools include approval preview formatter", async () => { | |
| 97 | + const result = await generateMcpTools({ | |
| 98 | + name: "answeroverflow", | |
| 99 | + url: "https://www.answeroverflow.com/mcp", | |
| 100 | + overrides: { | |
| 101 | + search_servers: { approval: "required" }, | |
| 102 | + }, | |
| 103 | + }); | |
| 104 | + | |
| 105 | + try { | |
| 106 | + let foundPreview = false; | |
| 107 | + walkToolTree(result.tools, (path, tool) => { | |
| 108 | + if (path === "answeroverflow.search_servers") { | |
| 109 | + const preview = tool.formatApproval?.({ query: "bun" }); | |
| 110 | + expect(preview).toBeDefined(); | |
| 111 | + expect(preview?.title).toBe("Run search_servers"); | |
| 112 | + expect(preview?.details).toContain("query"); | |
| 113 | + foundPreview = true; | |
| 114 | + } | |
| 115 | + }); | |
| 116 | + expect(foundPreview).toBe(true); | |
| 117 | + } finally { | |
| 118 | + await result.close(); | |
| 119 | + } | |
| 120 | + }, { timeout: 30_000 }); | |
| 94 | 121 | }); |
| @@ -53,6 +53,82 @@ export interface McpGenerateResult { · 52 unmodified lines | ||
| 53 | 53 | // Connection |
| 54 | 54 | // --------------------------------------------------------------------------- |
| 55 | 55 | |
| 56 | +const MAX_APPROVAL_PREVIEW = 240; | |
| 57 | + | |
| 58 | +function isRecord(value: unknown): value is Record<string, unknown> { | |
| 59 | + return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 60 | +} | |
| 61 | + | |
| 62 | +function toPreview(value: unknown): string { | |
| 63 | + if (value === undefined) return "undefined"; | |
| 64 | + if (value === null) return "null"; | |
| 65 | + try { | |
| 66 | + const text = typeof value === "string" ? value : JSON.stringify(value); | |
| 67 | + return text.length > MAX_APPROVAL_PREVIEW | |
| 68 | + ? `${text.slice(0, MAX_APPROVAL_PREVIEW)}...` | |
| 69 | + : text; | |
| 70 | + } catch { | |
| 71 | + return String(value); | |
| 72 | + } | |
| 73 | +} | |
| 74 | + | |
| 75 | +function inferMcpAction(toolName: string): { | |
| 76 | + action: "create" | "update" | "delete" | "read" | "execute" | "other"; | |
| 77 | + isDestructive: boolean; | |
| 78 | +} { | |
| 79 | + const lower = toolName.toLowerCase(); | |
| 80 | + if (/(delete|remove|destroy|purge)/.test(lower)) { | |
| 81 | + return { action: "delete", isDestructive: true }; | |
| 82 | + } | |
| 83 | + if (/(create|add|insert)/.test(lower)) { | |
| 84 | + return { action: "create", isDestructive: false }; | |
| 85 | + } | |
| 86 | + if (/(update|set|patch|edit)/.test(lower)) { | |
| 87 | + return { action: "update", isDestructive: false }; | |
| 88 | + } | |
| 89 | + if (/(get|list|search|find|read)/.test(lower)) { | |
| 90 | + return { action: "read", isDestructive: false }; | |
| 91 | + } | |
| 92 | + return { action: "execute", isDestructive: false }; | |
| 93 | +} | |
| 94 | + | |
| 95 | +function extractIds(input: unknown): string[] { | |
| 96 | + if (!isRecord(input)) return []; | |
| 97 | + const ids: string[] = []; | |
| 98 | + for (const key of ["id", "ids", "idOrName", "name", "slug", "key"]) { | |
| 99 | + const value = input[key]; | |
| 100 | + if (typeof value === "string" || typeof value === "number") { | |
| 101 | + ids.push(String(value)); | |
| 102 | + continue; | |
| 103 | + } | |
| 104 | + if (Array.isArray(value)) { | |
| 105 | + for (const item of value) { | |
| 106 | + if (typeof item === "string" || typeof item === "number") { | |
| 107 | + ids.push(String(item)); | |
| 108 | + } | |
| 109 | + } | |
| 110 | + } | |
| 111 | + } | |
| 112 | + return ids.slice(0, 5); | |
| 113 | +} | |
| 114 | + | |
| 115 | +function buildMcpApprovalPreview(toolName: string, input: unknown) { | |
| 116 | + const { action, isDestructive } = inferMcpAction(toolName); | |
| 117 | + const ids = extractIds(input); | |
| 118 | + const details = [ | |
| 119 | + ids.length > 0 ? `Target: ${ids.join(", ")}` : undefined, | |
| 120 | + `Arguments: ${toPreview(input)}`, | |
| 121 | + ].filter(Boolean).join("\n"); | |
| 122 | + return { | |
| 123 | + title: `Run ${toolName}`, | |
| 124 | + details, | |
| 125 | + action, | |
| 126 | + resourceType: toolName.replace(/[_-]/g, " "), | |
| 127 | + resourceIds: ids.length > 0 ? ids : undefined, | |
| 128 | + isDestructive, | |
| 129 | + } as const; | |
| 130 | +} | |
| 131 | + | |
| 56 | 132 | async function connectMcp( |
| 57 | 133 | url: string, |
| 58 | 134 | transport?: "sse" | "streamable-http", |
| @@ -146,6 +222,7 @@ export async function generateMcpTools( · 87 unmodified lines | ||
| 146 | 222 | } |
| 147 | 223 | return result.content; |
| 148 | 224 | }, |
| 225 | + formatApproval: (input) => buildMcpApprovalPreview(toolName, input), | |
| 149 | 226 | }); |
| 150 | 227 | |
| 151 | 228 | // Build TypeScript declaration |
| @@ -3,6 +3,56 @@ import { walkToolTree, isToolDefinition } from "@openassistant/core"; · 2 unmodified lines | ||
| 3 | 3 | import { generateOpenApiTools } from "./openapi.js"; |
| 4 | 4 | |
| 5 | 5 | describe("generateOpenApiTools — FastSpring spec", () => { |
| 6 | + test("generated write tools include approval preview formatter", async () => { | |
| 7 | + const result = await generateOpenApiTools({ | |
| 8 | + name: "vercel", | |
| 9 | + spec: { | |
| 10 | + openapi: "3.0.0", | |
| 11 | + info: { title: "Test", version: "1.0.0" }, | |
| 12 | + servers: [{ url: "https://api.example.com" }], | |
| 13 | + paths: { | |
| 14 | + "/v1/projects/{idOrName}": { | |
| 15 | + delete: { | |
| 16 | + tags: ["projects"], | |
| 17 | + operationId: "deleteProject", | |
| 18 | + summary: "Delete project", | |
| 19 | + parameters: [ | |
| 20 | + { | |
| 21 | + name: "idOrName", | |
| 22 | + in: "path", | |
| 23 | + required: true, | |
| 24 | + schema: { type: "string" }, | |
| 25 | + }, | |
| 26 | + ], | |
| 27 | + responses: { | |
| 28 | + "200": { | |
| 29 | + description: "ok", | |
| 30 | + content: { | |
| 31 | + "application/json": { | |
| 32 | + schema: { type: "object", properties: { ok: { type: "boolean" } } }, | |
| 33 | + }, | |
| 34 | + }, | |
| 35 | + }, | |
| 36 | + }, | |
| 37 | + }, | |
| 38 | + }, | |
| 39 | + }, | |
| 40 | + }, | |
| 41 | + }); | |
| 42 | + | |
| 43 | + const ns = result.tools["vercel"] as Record<string, unknown>; | |
| 44 | + const projects = ns["projects"] as Record<string, unknown>; | |
| 45 | + const deleteTool = projects["deleteProject"]; | |
| 46 | + | |
| 47 | + expect(isToolDefinition(deleteTool)).toBe(true); | |
| 48 | + const preview = deleteTool.formatApproval?.({ idOrName: "prj_123" }); | |
| 49 | + expect(preview).toBeDefined(); | |
| 50 | + expect(preview?.title).toBe("DELETE /v1/projects/{idOrName}"); | |
| 51 | + expect(preview?.details).toContain("Target: prj_123"); | |
| 52 | + expect(preview?.action).toBe("delete"); | |
| 53 | + expect(preview?.isDestructive).toBe(true); | |
| 54 | + }); | |
| 55 | + | |
| 6 | 56 | test("parses the FastSpring OpenAPI spec and generates tools", async () => { |
| 7 | 57 | const result = await generateOpenApiTools({ |
| 8 | 58 | name: "fastspring", |
| @@ -81,6 +81,7 @@ interface ParsedParameter { · 80 unmodified lines | ||
| 81 | 81 | // --------------------------------------------------------------------------- |
| 82 | 82 | |
| 83 | 83 | const READ_METHODS = new Set(["get", "head", "options"]); |
| 84 | +const MAX_APPROVAL_PREVIEW = 240; | |
| 84 | 85 | |
| 85 | 86 | |
| 86 | 87 | function buildAuthHeaders(auth: OpenApiAuth | undefined): Record<string, string> { |
| @@ -141,6 +142,109 @@ function sanitizeTag(tag: string): string { · 54 unmodified lines | ||
| 141 | 142 | .replace(/^_|_$/g, "") || "default"; |
| 142 | 143 | } |
| 143 | 144 | |
| 145 | +function isRecord(value: unknown): value is Record<string, unknown> { | |
| 146 | + return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 147 | +} | |
| 148 | + | |
| 149 | +function toPreview(value: unknown): string { | |
| 150 | + if (value === undefined) return "undefined"; | |
| 151 | + if (value === null) return "null"; | |
| 152 | + try { | |
| 153 | + const text = typeof value === "string" ? value : JSON.stringify(value); | |
| 154 | + return text.length > MAX_APPROVAL_PREVIEW | |
| 155 | + ? `${text.slice(0, MAX_APPROVAL_PREVIEW)}...` | |
| 156 | + : text; | |
| 157 | + } catch { | |
| 158 | + return String(value); | |
| 159 | + } | |
| 160 | +} | |
| 161 | + | |
| 162 | +function actionFromMethod(method: string): "create" | "update" | "delete" | "read" | "execute" { | |
| 163 | + switch (method.toLowerCase()) { | |
| 164 | + case "get": | |
| 165 | + case "head": | |
| 166 | + case "options": | |
| 167 | + return "read"; | |
| 168 | + case "post": | |
| 169 | + return "create"; | |
| 170 | + case "put": | |
| 171 | + case "patch": | |
| 172 | + return "update"; | |
| 173 | + case "delete": | |
| 174 | + return "delete"; | |
| 175 | + default: | |
| 176 | + return "execute"; | |
| 177 | + } | |
| 178 | +} | |
| 179 | + | |
| 180 | +function resourceTypeFromPath(path: string): string | undefined { | |
| 181 | + const segments = path.split("/").filter(Boolean).filter((segment) => !segment.startsWith("{")); | |
| 182 | + const raw = segments.at(-1); | |
| 183 | + if (!raw) return undefined; | |
| 184 | + const normalized = raw.endsWith("s") ? raw.slice(0, -1) : raw; | |
| 185 | + return normalized.replace(/[_-]/g, " "); | |
| 186 | +} | |
| 187 | + | |
| 188 | +function extractIds(input: unknown): string[] { | |
| 189 | + if (!isRecord(input)) return []; | |
| 190 | + const ids: string[] = []; | |
| 191 | + for (const key of ["id", "ids", "idOrName", "name", "slug", "projectId"]) { | |
| 192 | + const value = input[key]; | |
| 193 | + if (typeof value === "string" || typeof value === "number") { | |
| 194 | + ids.push(String(value)); | |
| 195 | + continue; | |
| 196 | + } | |
| 197 | + if (Array.isArray(value)) { | |
| 198 | + for (const item of value) { | |
| 199 | + if (typeof item === "string" || typeof item === "number") { | |
| 200 | + ids.push(String(item)); | |
| 201 | + } | |
| 202 | + } | |
| 203 | + } | |
| 204 | + } | |
| 205 | + return ids.slice(0, 5); | |
| 206 | +} | |
| 207 | + | |
| 208 | +function buildSelector(input: unknown): string | undefined { | |
| 209 | + if (!isRecord(input)) return undefined; | |
| 210 | + const selectors: string[] = []; | |
| 211 | + for (const [key, value] of Object.entries(input)) { | |
| 212 | + if (key === "id" || key === "ids" || key === "idOrName") continue; | |
| 213 | + if (value === undefined || value === null) continue; | |
| 214 | + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { | |
| 215 | + selectors.push(`${key}=${String(value)}`); | |
| 216 | + if (selectors.length >= 3) break; | |
| 217 | + } | |
| 218 | + } | |
| 219 | + return selectors.length > 0 ? selectors.join(", ") : undefined; | |
| 220 | +} | |
| 221 | + | |
| 222 | +function buildOpenApiApprovalPreview( | |
| 223 | + op: Pick<ParsedOperation, "method" | "path" | "operationId">, | |
| 224 | + input: unknown, | |
| 225 | +) { | |
| 226 | + const action = actionFromMethod(op.method); | |
| 227 | + const ids = extractIds(input); | |
| 228 | + const selector = buildSelector(input); | |
| 229 | + const resourceType = resourceTypeFromPath(op.path); | |
| 230 | + const title = `${op.method.toUpperCase()} ${op.path}`; | |
| 231 | + const detailsParts = [ | |
| 232 | + `Operation: ${op.operationId}`, | |
| 233 | + ids.length > 0 ? `Target: ${ids.join(", ")}` : undefined, | |
| 234 | + selector ? `Selector: ${selector}` : undefined, | |
| 235 | + `Arguments: ${toPreview(input)}`, | |
| 236 | + ].filter(Boolean); | |
| 237 | + return { | |
| 238 | + title, | |
| 239 | + details: detailsParts.join("\n"), | |
| 240 | + action, | |
| 241 | + resourceType, | |
| 242 | + resourceIds: ids.length > 0 ? ids : undefined, | |
| 243 | + isDestructive: action === "delete", | |
| 244 | + selector, | |
| 245 | + } as const; | |
| 246 | +} | |
| 247 | + | |
| 144 | 248 | // --------------------------------------------------------------------------- |
| 145 | 249 | // Spec parsing |
| 146 | 250 | // --------------------------------------------------------------------------- |
| @@ -366,6 +470,7 @@ export async function generateOpenApiTools( · 219 unmodified lines | ||
| 366 | 470 | } |
| 367 | 471 | return response.text(); |
| 368 | 472 | }, |
| 473 | + formatApproval: (input) => buildOpenApiApprovalPreview(op, input), | |
| 369 | 474 | }); |
| 370 | 475 | |
| 371 | 476 | // Build TypeScript declaration |