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
29interface PendingApproval {29interface PendingApproval {
30 readonly id: string;30 readonly id: string;
31 readonly toolPath: string;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
35interface TaskState {36interface TaskState {
@@ -65,7 +66,7 @@ function reduceEvent(state: TaskState, event: TaskEvent): TaskState { · 29 unmodified lines
65 ...state,66 ...state,
66 pendingApprovals: [67 pendingApprovals: [
67 ...state.pendingApprovals,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 statusMessage: "Waiting for approval...",71 statusMessage: "Waiting for approval...",
71 };72 };
@@ -214,6 +215,7 @@ export function TaskMessage({ taskId, prompt, api }: TaskMessageProps) { · 142 unmodified lines
214 215
215function ApprovalButtons({ approval, api }: { approval: PendingApproval; api: ApiClient }) {216function ApprovalButtons({ approval, api }: { approval: PendingApproval; api: ApiClient }) {
216 const [resolved, setResolved] = useState(false);217 const [resolved, setResolved] = useState(false);
218 const argsPreview = formatApprovalInput(approval.input);
217 219
218 const handle = async (decision: "approved" | "denied") => {220 const handle = async (decision: "approved" | "denied") => {
219 setResolved(true);221 setResolved(true);
@@ -228,8 +230,9 @@ function ApprovalButtons({ approval, api }: { approval: PendingApproval; api: Ap · 8 unmodified lines
228 <>230 <>
229 <Separator />231 <Separator />
230 <TextDisplay>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 </TextDisplay>234 </TextDisplay>
235 <TextDisplay>{`\`\`\`json\n${argsPreview}\n\`\`\``}</TextDisplay>
233 {!resolved && (236 {!resolved && (
234 <ActionRow>237 <ActionRow>
235 <Button label="Approve" style="success" onClick={() => handle("approved")} />238 <Button label="Approve" style="success" onClick={() => handle("approved")} />
@@ -252,3 +255,15 @@ function statusEmoji(status: TaskState["status"]): string { · 16 unmodified lines
252 case "cancelled": return "\u26d4";255 case "cancelled": return "\u26d4";
253 }256 }
254}257}
258
259function 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
186 ? `186 ? `
187## Tool Discovery187## Tool Discovery
188 188
189Some tool namespaces are too large to list here. Use \`tools.discover({ query: "..." })\` to search for tools by keyword.189Some tool namespaces are too large to list here. Use \`tools.discover({ query, depth? })\` to search for tools by keyword.
190It returns matching tool paths, descriptions, and TypeScript signatures.
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.191The \`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:**
1971. Discover tools at depth 0 to find what's available
1982. If you need to know response shapes, re-discover at depth 1 for the specific tools you'll use
1993. Write a SINGLE self-contained script that does the entire task
193 200
194Example — "close all open issues on acme/myapp":201Example — "close all open issues on acme/myapp":
195 202
196Call 1 (discovery):203Call 1 (find tools):
197\`\`\`ts204\`\`\`ts
198return await tools.discover({ query: "issues list update repo" });205return await tools.discover({ query: "issues list update repo" });
199\`\`\`206\`\`\`
200 207
201Call 2 (do the work — everything in one script):208Call 2 (get return types for the tools you'll use):
209\`\`\`ts
210return await tools.discover({ query: "issues list_for_repo update", depth: 1 });
211\`\`\`
212
213Call 3 (do the work — everything in one script):
202\`\`\`ts214\`\`\`ts
203const issues = await tools.github.issues.issues_list_for_repo({215const issues = await tools.github.issues.issues_list_for_repo({
204 owner: "acme", repo: "myapp", state: "open", per_page: 100216 owner: "acme", repo: "myapp", state: "open", per_page: 100
packages/core/src/discovery.ts+64−14
@@ -1,10 +1,12 @@
1/**1/**
2 * Tool discovery — searchable index of tools for large APIs.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,4 * The LLM uses `tools.discover({ query, depth? })` to find tools and
5 * the agent uses `tools.discover({ query })` to search for relevant5 * control how much type detail to retrieve:
6 * tools by name, path, and description. Returns type signatures so6 *
7 * the LLM knows how to call what it finds.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 * The discovered tools are already wired in the sandbox — the LLM11 * The discovered tools are already wired in the sandbox — the LLM
10 * just doesn't get told about all of them in the system prompt.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 readonly description: string;28 readonly description: string;
27 /** Approval mode */29 /** Approval mode */
28 readonly approval: "auto" | "required";30 readonly approval: "auto" | "required";
29 /** TypeScript signature: "(input: { ... }): Promise<{ ... }>" */31 /** Full args type string (always available) */
30 readonly signature: string;32 readonly argsType: string;
33 /** Full returns type string (may be large for OpenAPI tools) */
34 readonly returnsType: string;
31 /** Searchable text (lowercase): path + description */35 /** Searchable text (lowercase): path + description */
32 readonly searchText: string;36 readonly searchText: string;
33}37}
@@ -44,13 +48,12 @@ export function buildToolIndex(tree: ToolTree): ToolIndexEntry[] { · 10 unmodified lines
44 const path = prefix ? `${prefix}.${key}` : key;48 const path = prefix ? `${prefix}.${key}` : key;
45 if (isToolDefinition(value)) {49 if (isToolDefinition(value)) {
46 const tool = value as ToolDefinition;50 const tool = value as ToolDefinition;
47 const argsType = getArgsTypeString(tool);
48 const returnsType = getReturnsTypeString(tool);
49 entries.push({51 entries.push({
50 path,52 path,
51 description: tool.description,53 description: tool.description,
52 approval: tool.approval,54 approval: tool.approval,
53 signature: `(input: ${argsType}): Promise<${returnsType}>`,55 argsType: getArgsTypeString(tool),
56 returnsType: getReturnsTypeString(tool),
54 searchText: `${path} ${tool.description}`.toLowerCase(),57 searchText: `${path} ${tool.description}`.toLowerCase(),
55 });58 });
56 } else {59 } else {
@@ -63,6 +66,38 @@ export function buildToolIndex(tree: ToolTree): ToolIndexEntry[] { · 6 unmodified lines
63 return entries;66 return entries;
64}67}
65 68
69// ---------------------------------------------------------------------------
70// Strip JSDoc comments from type strings
71// ---------------------------------------------------------------------------
72
73function 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 */
90function 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// Search102// Search
68// ---------------------------------------------------------------------------103// ---------------------------------------------------------------------------
@@ -128,9 +163,17 @@ export interface DiscoverToolOptions { · 59 unmodified lines
128 * Create a `discover` tool that searches the tool index.163 * Create a `discover` tool that searches the tool index.
129 *164 *
130 * Usage in LLM-generated code:165 * Usage in LLM-generated code:
166 * // Step 1: find tools (cheap — no return types)
131 * const results = await tools.discover({ query: "feature flags" });167 * const results = await tools.discover({ query: "feature flags" });
132 * // results is an array of { path, description, approval, signature }168 *
133 * // Then call: await tools.posthog.feature_flags.list({ ... })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 */
135export function createDiscoverTool(178export function createDiscoverTool(
136 tree: ToolTree,179 tree: ToolTree,
@@ -140,10 +183,16 @@ export function createDiscoverTool( · 3 unmodified lines
140 const maxResults = options?.maxResults ?? 20;183 const maxResults = options?.maxResults ?? 20;
141 184
142 return defineTool({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
188depth 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 approval: "auto" as const,192 approval: "auto" as const,
145 args: z.object({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 returns: z.object({197 returns: z.object({
149 results: z.array(z.object({198 results: z.array(z.object({
@@ -155,13 +204,14 @@ export function createDiscoverTool( · 5 unmodified lines
155 total: z.number(),204 total: z.number(),
156 }),205 }),
157 run: async (input) => {206 run: async (input) => {
207 const depth = input.depth ?? 0;
158 const results = searchIndex(index, input.query, maxResults);208 const results = searchIndex(index, input.query, maxResults);
159 return {209 return {
160 results: results.map((e) => ({210 results: results.map((e) => ({
161 path: e.path,211 path: e.path,
162 description: e.description,212 description: e.description,
163 approval: e.approval,213 approval: e.approval,
164 signature: e.signature,214 signature: formatSignature(e, depth),
165 })),215 })),
166 total: results.length,216 total: results.length,
167 };217 };
packages/core/src/runner.test.ts+43−0
@@ -329,6 +329,49 @@ describe("runner — approval flow", () => { · 328 unmodified lines
329 const req = capturedRequest as { preview: { title: string } };329 const req = capturedRequest as { preview: { title: string } };
330 expect(req.preview.title).toBe("Delete user_42 from users");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
334describe("runner — error handling", () => {377describe("runner — error handling", () => {
packages/core/src/runner.ts+92−1
@@ -53,6 +53,7 @@ export interface Runner { · 52 unmodified lines
53// ---------------------------------------------------------------------------53// ---------------------------------------------------------------------------
54 54
55const MAX_PREVIEW = 180;55const MAX_PREVIEW = 180;
56const MAX_APPROVAL_DETAILS = 500;
56 57
57function preview(value: unknown): string {58function preview(value: unknown): string {
58 if (value === undefined) return "undefined";59 if (value === undefined) return "undefined";
@@ -65,6 +66,96 @@ function preview(value: unknown): string { · 6 unmodified lines
65 }66 }
66}67}
67 68
69function isRecord(value: unknown): value is Record<string, unknown> {
70 return typeof value === "object" && value !== null && !Array.isArray(value);
71}
72
73function 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
93function 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
102function 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
123function 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
129function 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
68function describeError(error: unknown): string {159function describeError(error: unknown): string {
69 if (error instanceof Error) {160 if (error instanceof Error) {
70 // Include cause if present (e.g. nested API errors)161 // Include cause if present (e.g. nested API errors)
@@ -177,7 +268,7 @@ function createToolInvoker( · 106 unmodified lines
177 if (tool.approval === "required") {268 if (tool.approval === "required") {
178 const presentation: ApprovalPresentation = tool.formatApproval269 const presentation: ApprovalPresentation = tool.formatApproval
179 ? tool.formatApproval(validatedInput)270 ? tool.formatApproval(validatedInput)
180 : { title: `Approve ${toolPath}` };271 : buildDefaultApprovalPresentation(toolPath, validatedInput);
181 272
182 const decision = await ctx.requestApproval({273 const decision = await ctx.requestApproval({
183 callId,274 callId,
packages/core/src/tools.ts+10−0
@@ -27,6 +27,16 @@ export interface ApprovalPresentation { · 26 unmodified lines
27 title: string;27 title: string;
28 details?: string;28 details?: string;
29 link?: string;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/**
packages/tool-gen/src/mcp.test.ts+27−0
@@ -30,6 +30,7 @@ describe("generateMcpTools — AnswerOverflow MCP", () => { · 29 unmodified lines
30 expect(tool.description).toBeDefined();30 expect(tool.description).toBeDefined();
31 expect(tool.description.length).toBeGreaterThan(0);31 expect(tool.description.length).toBeGreaterThan(0);
32 expect(tool.approval).toBe("auto"); // default32 expect(tool.approval).toBe("auto"); // default
33 expect(typeof tool.formatApproval).toBe("function");
33 });34 });
34 35
35 // TypeScript declarations should be generated36 // TypeScript declarations should be generated
@@ -91,4 +92,30 @@ describe("generateMcpTools — AnswerOverflow MCP", () => { · 55 unmodified lines
91 await result.close();92 await result.close();
92 }93 }
93 }, { timeout: 30_000 });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});
packages/tool-gen/src/mcp.ts+77−0
@@ -53,6 +53,82 @@ export interface McpGenerateResult { · 52 unmodified lines
53// Connection53// Connection
54// ---------------------------------------------------------------------------54// ---------------------------------------------------------------------------
55 55
56const MAX_APPROVAL_PREVIEW = 240;
57
58function isRecord(value: unknown): value is Record<string, unknown> {
59 return typeof value === "object" && value !== null && !Array.isArray(value);
60}
61
62function 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
75function 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
95function 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
115function 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
56async function connectMcp(132async function connectMcp(
57 url: string,133 url: string,
58 transport?: "sse" | "streamable-http",134 transport?: "sse" | "streamable-http",
@@ -146,6 +222,7 @@ export async function generateMcpTools( · 87 unmodified lines
146 }222 }
147 return result.content;223 return result.content;
148 },224 },
225 formatApproval: (input) => buildMcpApprovalPreview(toolName, input),
149 });226 });
150 227
151 // Build TypeScript declaration228 // 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
3import { generateOpenApiTools } from "./openapi.js";3import { generateOpenApiTools } from "./openapi.js";
4 4
5describe("generateOpenApiTools — FastSpring spec", () => {5describe("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 test("parses the FastSpring OpenAPI spec and generates tools", async () => {56 test("parses the FastSpring OpenAPI spec and generates tools", async () => {
7 const result = await generateOpenApiTools({57 const result = await generateOpenApiTools({
8 name: "fastspring",58 name: "fastspring",
packages/tool-gen/src/openapi.ts+105−0
@@ -81,6 +81,7 @@ interface ParsedParameter { · 80 unmodified lines
81// ---------------------------------------------------------------------------81// ---------------------------------------------------------------------------
82 82
83const READ_METHODS = new Set(["get", "head", "options"]);83const READ_METHODS = new Set(["get", "head", "options"]);
84const MAX_APPROVAL_PREVIEW = 240;
84 85
85 86
86function buildAuthHeaders(auth: OpenApiAuth | undefined): Record<string, string> {87function buildAuthHeaders(auth: OpenApiAuth | undefined): Record<string, string> {
@@ -141,6 +142,109 @@ function sanitizeTag(tag: string): string { · 54 unmodified lines
141 .replace(/^_|_$/g, "") || "default";142 .replace(/^_|_$/g, "") || "default";
142}143}
143 144
145function isRecord(value: unknown): value is Record<string, unknown> {
146 return typeof value === "object" && value !== null && !Array.isArray(value);
147}
148
149function 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
162function 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
180function 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
188function 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
208function 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
222function 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// Spec parsing249// Spec parsing
146// ---------------------------------------------------------------------------250// ---------------------------------------------------------------------------
@@ -366,6 +470,7 @@ export async function generateOpenApiTools( · 219 unmodified lines
366 }470 }
367 return response.text();471 return response.text();
368 },472 },
473 formatApproval: (input) => buildOpenApiApprovalPreview(op, input),
369 });474 });
370 475
371 // Build TypeScript declaration476 // Build TypeScript declaration