History

Enhance approval flow with detailed previews and structured metadata. Update task-message and core files to support new input handling and approval presentation, including action types and resource identification. Improve OpenAPI and MCP tool generation to include approval formatters for better user feedback.

Rhys Sullivan committed 9 months ago

Commit 36becaa1 parent db0c5e410 files · +503 −23Browse files

Unified · Split

10 files
  1. apps/bot/src/views/task-message.tsx +18 −3
  2. packages/core/src/agent.ts +17 −5
  3. packages/core/src/discovery.ts +64 −14
  4. packages/core/src/runner.test.ts +43 −0
  5. packages/core/src/runner.ts +92 −1
  6. packages/core/src/tools.ts +10 −0
  7. packages/tool-gen/src/mcp.test.ts +27 −0
  8. packages/tool-gen/src/mcp.ts +77 −0
  9. packages/tool-gen/src/openapi.test.ts +50 −0
  10. packages/tool-gen/src/openapi.ts +105 −0
apps/bot/src/views/task-message.tsx+18−3
@@ -29,7 +29,8 @@ import type { TaskEvent } from "@openassistant/core/events"; · 28 unmodified lines
2929 interface PendingApproval {
3030 readonly id: string;
3131 readonly toolPath: string;
32− readonly preview: { title: string; details?: string };
32+ readonly input: unknown;
33+ readonly preview: { title: string; details?: string; link?: string };
3334 }
3435
3536 interface TaskState {
@@ -65,7 +66,7 @@ function reduceEvent(state: TaskState, event: TaskEvent): TaskState { · 29 unmodified lines
6566 ...state,
6667 pendingApprovals: [
6768 ...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 },
6970 ],
7071 statusMessage: "Waiting for approval...",
7172 };
@@ -214,6 +215,7 @@ export function TaskMessage({ taskId, prompt, api }: TaskMessageProps) { · 142 unmodified lines
214215
215216 function ApprovalButtons({ approval, api }: { approval: PendingApproval; api: ApiClient }) {
216217 const [resolved, setResolved] = useState(false);
218+ const argsPreview = formatApprovalInput(approval.input);
217219
218220 const handle = async (decision: "approved" | "denied") => {
219221 setResolved(true);
@@ -228,8 +230,9 @@ function ApprovalButtons({ approval, api }: { approval: PendingApproval; api: Ap · 8 unmodified lines
228230 <>
229231 <Separator />
230232 <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}` : ""}`}
232234 </TextDisplay>
235+ <TextDisplay>{`\`\`\`json\n${argsPreview}\n\`\`\``}</TextDisplay>
233236 {!resolved && (
234237 <ActionRow>
235238 <Button label="Approve" style="success" onClick={() => handle("approved")} />
@@ -252,3 +255,15 @@ function statusEmoji(status: TaskState["status"]): string { · 16 unmodified lines
252255 case "cancelled": return "\u26d4";
253256 }
254257 }
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+}
packages/core/src/agent.ts+17−5
@@ -186,19 +186,31 @@ function buildSystemPrompt(guidance: string, discoveryMode: boolean): string { · 185 unmodified lines
186186 ? `
187187 ## Tool Discovery
188188
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.
191190
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
193200
194201 Example — "close all open issues on acme/myapp":
195202
196−Call 1 (discovery):
203+Call 1 (find tools):
197204 \`\`\`ts
198205 return await tools.discover({ query: "issues list update repo" });
199206 \`\`\`
200207
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):
202214 \`\`\`ts
203215 const issues = await tools.github.issues.issues_list_for_repo({
204216 owner: "acme", repo: "myapp", state: "open", per_page: 100
packages/core/src/discovery.ts+64−14
@@ -1,10 +1,12 @@
11 /**
22 * Tool discovery — searchable index of tools for large APIs.
33 *
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.
810 *
911 * The discovered tools are already wired in the sandbox — the LLM
1012 * just doesn't get told about all of them in the system prompt.
@@ -26,8 +28,10 @@ export interface ToolIndexEntry { · 15 unmodified lines
2628 readonly description: string;
2729 /** Approval mode */
2830 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;
3135 /** Searchable text (lowercase): path + description */
3236 readonly searchText: string;
3337 }
@@ -44,13 +48,12 @@ export function buildToolIndex(tree: ToolTree): ToolIndexEntry[] { · 10 unmodified lines
4448 const path = prefix ? `${prefix}.${key}` : key;
4549 if (isToolDefinition(value)) {
4650 const tool = value as ToolDefinition;
47− const argsType = getArgsTypeString(tool);
48− const returnsType = getReturnsTypeString(tool);
4951 entries.push({
5052 path,
5153 description: tool.description,
5254 approval: tool.approval,
53− signature: `(input: ${argsType}): Promise<${returnsType}>`,
55+ argsType: getArgsTypeString(tool),
56+ returnsType: getReturnsTypeString(tool),
5457 searchText: `${path} ${tool.description}`.toLowerCase(),
5558 });
5659 } else {
@@ -63,6 +66,38 @@ export function buildToolIndex(tree: ToolTree): ToolIndexEntry[] { · 6 unmodified lines
6366 return entries;
6467 }
6568
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+
66101 // ---------------------------------------------------------------------------
67102 // Search
68103 // ---------------------------------------------------------------------------
@@ -128,9 +163,17 @@ export interface DiscoverToolOptions { · 59 unmodified lines
128163 * Create a `discover` tool that searches the tool index.
129164 *
130165 * Usage in LLM-generated code:
166+ * // Step 1: find tools (cheap — no return types)
131167 * 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({ ... })
134177 */
135178 export function createDiscoverTool(
136179 tree: ToolTree,
@@ -140,10 +183,16 @@ export function createDiscoverTool( · 3 unmodified lines
140183 const maxResults = options?.maxResults ?? 20;
141184
142185 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.`,
144192 approval: "auto" as const,
145193 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"),
147196 }),
148197 returns: z.object({
149198 results: z.array(z.object({
@@ -155,13 +204,14 @@ export function createDiscoverTool( · 5 unmodified lines
155204 total: z.number(),
156205 }),
157206 run: async (input) => {
207+ const depth = input.depth ?? 0;
158208 const results = searchIndex(index, input.query, maxResults);
159209 return {
160210 results: results.map((e) => ({
161211 path: e.path,
162212 description: e.description,
163213 approval: e.approval,
164− signature: e.signature,
214+ signature: formatSignature(e, depth),
165215 })),
166216 total: results.length,
167217 };
packages/core/src/runner.test.ts+43−0
@@ -329,6 +329,49 @@ describe("runner — approval flow", () => { · 328 unmodified lines
329329 const req = capturedRequest as { preview: { title: string } };
330330 expect(req.preview.title).toBe("Delete user_42 from users");
331331 });
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+ });
332375 });
333376
334377 describe("runner — error handling", () => {
packages/core/src/runner.ts+92−1
@@ -53,6 +53,7 @@ export interface Runner { · 52 unmodified lines
5353 // ---------------------------------------------------------------------------
5454
5555 const MAX_PREVIEW = 180;
56+const MAX_APPROVAL_DETAILS = 500;
5657
5758 function preview(value: unknown): string {
5859 if (value === undefined) return "undefined";
@@ -65,6 +66,96 @@ function preview(value: unknown): string { · 6 unmodified lines
6566 }
6667 }
6768
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+
68159 function describeError(error: unknown): string {
69160 if (error instanceof Error) {
70161 // Include cause if present (e.g. nested API errors)
@@ -177,7 +268,7 @@ function createToolInvoker( · 106 unmodified lines
177268 if (tool.approval === "required") {
178269 const presentation: ApprovalPresentation = tool.formatApproval
179270 ? tool.formatApproval(validatedInput)
180− : { title: `Approve ${toolPath}` };
271+ : buildDefaultApprovalPresentation(toolPath, validatedInput);
181272
182273 const decision = await ctx.requestApproval({
183274 callId,
packages/core/src/tools.ts+10−0
@@ -27,6 +27,16 @@ export interface ApprovalPresentation { · 26 unmodified lines
2727 title: string;
2828 details?: string;
2929 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;
3040 }
3141
3242 /**
packages/tool-gen/src/mcp.test.ts+27−0
@@ -30,6 +30,7 @@ describe("generateMcpTools — AnswerOverflow MCP", () => { · 29 unmodified lines
3030 expect(tool.description).toBeDefined();
3131 expect(tool.description.length).toBeGreaterThan(0);
3232 expect(tool.approval).toBe("auto"); // default
33+ expect(typeof tool.formatApproval).toBe("function");
3334 });
3435
3536 // TypeScript declarations should be generated
@@ -91,4 +92,30 @@ describe("generateMcpTools — AnswerOverflow MCP", () => { · 55 unmodified lines
9192 await result.close();
9293 }
9394 }, { 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 });
94121 });
packages/tool-gen/src/mcp.ts+77−0
@@ -53,6 +53,82 @@ export interface McpGenerateResult { · 52 unmodified lines
5353 // Connection
5454 // ---------------------------------------------------------------------------
5555
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+
56132 async function connectMcp(
57133 url: string,
58134 transport?: "sse" | "streamable-http",
@@ -146,6 +222,7 @@ export async function generateMcpTools( · 87 unmodified lines
146222 }
147223 return result.content;
148224 },
225+ formatApproval: (input) => buildMcpApprovalPreview(toolName, input),
149226 });
150227
151228 // Build TypeScript declaration
packages/tool-gen/src/openapi.test.ts+50−0
@@ -3,6 +3,56 @@ import { walkToolTree, isToolDefinition } from "@openassistant/core"; · 2 unmodified lines
33 import { generateOpenApiTools } from "./openapi.js";
44
55 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+
656 test("parses the FastSpring OpenAPI spec and generates tools", async () => {
757 const result = await generateOpenApiTools({
858 name: "fastspring",
packages/tool-gen/src/openapi.ts+105−0
@@ -81,6 +81,7 @@ interface ParsedParameter { · 80 unmodified lines
8181 // ---------------------------------------------------------------------------
8282
8383 const READ_METHODS = new Set(["get", "head", "options"]);
84+const MAX_APPROVAL_PREVIEW = 240;
8485
8586
8687 function buildAuthHeaders(auth: OpenApiAuth | undefined): Record<string, string> {
@@ -141,6 +142,109 @@ function sanitizeTag(tag: string): string { · 54 unmodified lines
141142 .replace(/^_|_$/g, "") || "default";
142143 }
143144
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+
144248 // ---------------------------------------------------------------------------
145249 // Spec parsing
146250 // ---------------------------------------------------------------------------
@@ -366,6 +470,7 @@ export async function generateOpenApiTools( · 219 unmodified lines
366470 }
367471 return response.text();
368472 },
473+ formatApproval: (input) => buildOpenApiApprovalPreview(op, input),
369474 });
370475
371476 // Build TypeScript declaration