@taskade/mcp-openapi-codegen
Advanced tools
| import { describe, expect, it } from 'vitest'; | ||
| import { normalizeAllOf } from './openapi'; | ||
| describe('normalizeAllOf', () => { | ||
| it('merges plain allOf object members into a single object schema', () => { | ||
| const out = normalizeAllOf({ | ||
| allOf: [ | ||
| { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] }, | ||
| { type: 'object', properties: { b: { type: 'number' } }, required: ['b'] }, | ||
| ], | ||
| } as never) as Record<string, never>; | ||
| expect((out as Record<string, unknown>).allOf).toBeUndefined(); | ||
| expect(out.properties).toHaveProperty('a'); | ||
| expect(out.properties).toHaveProperty('b'); | ||
| expect(((out as Record<string, string[]>).required ?? []).sort()).toEqual(['a', 'b']); | ||
| }); | ||
| // Regression for #34: taskCreate combined an allOf base ({content, contentType}) | ||
| // with an anyOf whose branches used additionalProperties:false (.strict()). The | ||
| // strict branch rejected the sibling content/contentType, causing -32602. | ||
| // normalizeAllOf must distribute the siblings into every branch. | ||
| it('distributes sibling properties into strict anyOf branches (#34)', () => { | ||
| const out = normalizeAllOf({ | ||
| allOf: [ | ||
| { | ||
| type: 'object', | ||
| properties: { content: { type: 'string' }, contentType: { type: 'string' } }, | ||
| required: ['content', 'contentType'], | ||
| }, | ||
| { | ||
| anyOf: [ | ||
| { | ||
| type: 'object', | ||
| properties: { placement: { type: 'string' } }, | ||
| additionalProperties: false, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| } as never) as Record<string, unknown>; | ||
| expect(out.allOf).toBeUndefined(); | ||
| expect(Array.isArray(out.anyOf)).toBe(true); | ||
| const branch = (out.anyOf as Array<{ properties: Record<string, unknown> }>)[0]; | ||
| // The strict branch now also knows about the sibling keys, so a payload | ||
| // carrying content + contentType + placement validates instead of being rejected. | ||
| expect(branch.properties).toHaveProperty('content'); | ||
| expect(branch.properties).toHaveProperty('contentType'); | ||
| expect(branch.properties).toHaveProperty('placement'); | ||
| }); | ||
| it('returns non-allOf schemas unchanged', () => { | ||
| const schema = { type: 'object', properties: { x: { type: 'string' } } }; | ||
| expect(normalizeAllOf(schema as never)).toEqual(schema); | ||
| }); | ||
| }); |
| import { describe, expect, it } from 'vitest'; | ||
| import { deriveToolName, parseOpenApi } from './parser'; | ||
| describe('deriveToolName', () => { | ||
| it('derives a camelCase name from a flat RPC path (API v2)', () => { | ||
| expect(deriveToolName('post', '/promptAgent')).toBe('promptAgent'); | ||
| expect(deriveToolName('post', '/subscribeWebhook')).toBe('subscribeWebhook'); | ||
| expect(deriveToolName('post', '/listConversations')).toBe('listConversations'); | ||
| }); | ||
| it('drops path params and camelCases remaining segments', () => { | ||
| expect(deriveToolName('get', '/media/{mediaId}/content')).toBe('mediaContent'); | ||
| expect(deriveToolName('get', '/bundles/{spaceId}/export/zip')).toBe('bundlesExportZip'); | ||
| }); | ||
| it('camelCases hyphen- and underscore-separated segments', () => { | ||
| expect(deriveToolName('post', '/list-conversations')).toBe('listConversations'); | ||
| expect(deriveToolName('get', '/user_profile')).toBe('userProfile'); | ||
| }); | ||
| it('falls back to the HTTP method for a root or param-only path', () => { | ||
| expect(deriveToolName('get', '/')).toBe('get'); | ||
| expect(deriveToolName('POST', '/{id}')).toBe('post'); | ||
| }); | ||
| }); | ||
| describe('parseOpenApi name resolution', () => { | ||
| it('prefers operationId when present (API v1, unchanged behavior)', () => { | ||
| const tools = parseOpenApi({ | ||
| '/projects': { | ||
| post: { operationId: 'projectCreate', description: 'Create a project', responses: {} }, | ||
| }, | ||
| } as never); | ||
| expect(tools).toHaveLength(1); | ||
| expect(tools[0].name).toBe('projectCreate'); | ||
| expect(tools[0].description).toBe('Create a project'); | ||
| }); | ||
| it('derives the name from the path and uses summary as description when operationId is absent (API v2)', () => { | ||
| const tools = parseOpenApi({ | ||
| '/promptAgent': { post: { summary: 'Prompt an agent', responses: {} } }, | ||
| } as never); | ||
| expect(tools).toHaveLength(1); | ||
| expect(tools[0].name).toBe('promptAgent'); | ||
| expect(tools[0].description).toBe('Prompt an agent'); | ||
| }); | ||
| it('falls back to an empty description when neither description nor summary is present', () => { | ||
| const tools = parseOpenApi({ | ||
| '/promptAgent': { post: { responses: {} } }, | ||
| } as never); | ||
| expect(tools).toHaveLength(1); | ||
| expect(tools[0].description).toBe(''); | ||
| }); | ||
| it('keeps request-body params when the body schema is nullable (API v2 promptAgent)', () => { | ||
| const tools = parseOpenApi({ | ||
| '/promptAgent': { | ||
| post: { | ||
| summary: 'Prompt an agent', | ||
| requestBody: { | ||
| content: { | ||
| 'application/json': { | ||
| schema: { | ||
| type: 'object', | ||
| nullable: true, | ||
| properties: { | ||
| spaceId: { type: 'string' }, | ||
| agentId: { type: 'string' }, | ||
| prompt: { type: 'string' }, | ||
| }, | ||
| required: ['spaceId', 'agentId', 'prompt'], | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| responses: {}, | ||
| }, | ||
| }, | ||
| } as never); | ||
| expect(tools).toHaveLength(1); | ||
| expect(Object.keys(tools[0].inputSchema.properties ?? {})).toEqual([ | ||
| 'spaceId', | ||
| 'agentId', | ||
| 'prompt', | ||
| ]); | ||
| expect(tools[0].inputSchema.required).toEqual(['spaceId', 'agentId', 'prompt']); | ||
| }); | ||
| }); |
| import { describe, expect, it } from 'vitest'; | ||
| import { prepareToolCallOperation } from './runtime'; | ||
| describe('prepareToolCallOperation', () => { | ||
| it('splits input into path params, query params, and JSON body', () => { | ||
| const result = prepareToolCallOperation({ | ||
| name: 'taskCreate', | ||
| path: '/projects/{projectId}/tasks/', | ||
| method: 'POST', | ||
| input: { projectId: 'p1', limit: 10, content: 'hello' }, | ||
| pathParamKeys: ['projectId'], | ||
| queryParamKeys: ['limit'], | ||
| }); | ||
| expect(result.url).toBe('/projects/p1/tasks/?limit=10'); | ||
| expect(result.method).toBe('POST'); | ||
| expect(JSON.parse(result.body as string)).toEqual({ content: 'hello' }); | ||
| expect(result.headers['Content-Type']).toBe('application/json'); | ||
| }); | ||
| it('omits the body and content-type when there are no body params', () => { | ||
| const result = prepareToolCallOperation({ | ||
| name: 'projectGet', | ||
| path: '/projects/{projectId}', | ||
| method: 'GET', | ||
| input: { projectId: 'p1' }, | ||
| pathParamKeys: ['projectId'], | ||
| queryParamKeys: [], | ||
| }); | ||
| expect(result.url).toBe('/projects/p1'); | ||
| expect(result.body).toBeUndefined(); | ||
| expect(result.headers['Content-Type']).toBeUndefined(); | ||
| }); | ||
| }); |
+25
-0
| # @taskade/mcp-openapi-codegen | ||
| ## 0.0.4 | ||
| ### Patch Changes | ||
| - [#53](https://github.com/taskade/mcp/pull/53) [`93017a7`](https://github.com/taskade/mcp/commit/93017a77cca91b57b7518648f2dd21010ef9ca7d) Thanks [@johnxie](https://github.com/johnxie)! - Derive a camelCase tool name from an operation's path when the OpenAPI spec omits | ||
| `operationId` (and fall back to `summary` for the description). Enables generating | ||
| tools from specs like Taskade API v2's flat RPC routes (`POST /promptAgent`). Specs | ||
| that provide `operationId` (e.g. Taskade v1) are unaffected. | ||
| - [#55](https://github.com/taskade/mcp/pull/55) [`f4c9cf5`](https://github.com/taskade/mcp/commit/f4c9cf55c269d5fd5cc1c2d42317e5c84816af95) Thanks [@johnxie](https://github.com/johnxie)! - Add a Taskade API **v2** tool layer alongside the existing v1 tools (additive — v1's | ||
| 57 tools are unchanged). Exposes the highest-value capabilities v1 lacks: **agent chat** | ||
| (`promptAgent`, `listConversations`, `getConversation`) and **webhooks** | ||
| (`subscribeWebhook`, `unsubscribeWebhook`). The codegen gains an `exportName` option so | ||
| the second tool set (`setupToolsV2`) can be registered next to the first. v2 is beta; | ||
| the enabled set will grow as it stabilizes. | ||
| ## 0.0.3 | ||
| ### Patch Changes | ||
| - [#44](https://github.com/taskade/mcp/pull/44) [`ff6a9da`](https://github.com/taskade/mcp/commit/ff6a9da911f0879557c74ee594e9f1d9a1d94067) Thanks [@johnxie](https://github.com/johnxie)! - Add MCP tool annotations to every generated tool: a human-friendly `title` | ||
| (from the humanized action map) plus `readOnlyHint`/`destructiveHint` derived | ||
| from each operation's HTTP method (GET/HEAD → read-only, DELETE → destructive). | ||
| Improves client UX/safety display and is a prerequisite for connector directories. | ||
| ## 0.0.2 | ||
@@ -4,0 +29,0 @@ |
+1
-1
| { | ||
| "name": "@taskade/mcp-openapi-codegen", | ||
| "version": "0.0.2", | ||
| "version": "0.0.4", | ||
| "author": "Prev Wong", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+23
-13
@@ -45,2 +45,8 @@ import fs from 'fs'; | ||
| actions?: Record<string, ActionConfig>; | ||
| /** | ||
| * Name of the generated setup function. Defaults to `setupTools`. Override it | ||
| * (e.g. `setupToolsV2`) so a second generated tool set can be imported alongside | ||
| * the first without an export-name collision. | ||
| */ | ||
| exportName?: string; | ||
| }; | ||
@@ -50,2 +56,3 @@ | ||
| const { document, path: outputPath } = opts; | ||
| const exportName = opts.exportName ?? 'setupTools'; | ||
@@ -67,3 +74,3 @@ const tools = parseOpenApi(document.paths ?? {}); | ||
| export const setupTools = (server: McpServer, opts: OpenAPIToolRuntimeConfigOpts) => { | ||
| export const ${exportName} = (server: McpServer, opts: OpenAPIToolRuntimeConfigOpts) => { | ||
@@ -79,19 +86,22 @@ const config = new OpenAPIToolRuntimeConfig(opts); | ||
| const annotations: Record<string, any> = {}; | ||
| // Derive MCP tool annotations from the HTTP method: GET/HEAD are read-only, | ||
| // DELETE is destructive. A human-friendly title can be supplied via opts.actions. | ||
| const method = tool.method.toUpperCase(); | ||
| const annotations: Record<string, any> = { | ||
| readOnlyHint: method === 'GET' || method === 'HEAD', | ||
| destructiveHint: method === 'DELETE', | ||
| }; | ||
| if (opts.actions?.[tool.name]) { | ||
| annotations.title = opts.actions[tool.name].title; | ||
| annotations.description = opts.actions[tool.name].description; | ||
| const actionTitle = opts.actions?.[tool.name]?.title; | ||
| if (actionTitle) { | ||
| annotations.title = actionTitle; | ||
| } | ||
| const toolArgs = [ | ||
| `"${tool.name}"`, | ||
| `"${tool.description}"`, | ||
| generateToolInputFromParsedTool(tool), | ||
| ]; | ||
| const description = opts.actions?.[tool.name]?.description ?? tool.description; | ||
| if (Object.keys(annotations).length > 0) { | ||
| toolArgs.push(JSON.stringify(annotations)); | ||
| } | ||
| const toolArgs = [`"${tool.name}"`, `"${description}"`, generateToolInputFromParsedTool(tool)]; | ||
| // annotations always carry read-only/destructive hints, so always include them | ||
| toolArgs.push(JSON.stringify(annotations)); | ||
| toolArgs.push(`async (args) => { | ||
@@ -98,0 +108,0 @@ return await config.executeToolCall({ |
+40
-3
@@ -24,2 +24,31 @@ import type { JSONSchema7 as IJsonSchema } from 'json-schema'; | ||
| /** | ||
| * Derive a stable camelCase tool name from an operation's path when the spec omits | ||
| * `operationId` — e.g. Taskade API v2's flat RPC routes (`POST /promptAgent`). Path | ||
| * params (`{id}`) are dropped and remaining segments are camelCased | ||
| * (`/media/{mediaId}/content` → `mediaContent`). Falls back to the HTTP method for a | ||
| * root or param-only path (`/`, `/{id}`). Specs that DO provide `operationId` (e.g. | ||
| * Taskade v1) are unaffected. | ||
| */ | ||
| export const deriveToolName = (method: string, path: string): string => { | ||
| const words = path | ||
| .split('/') | ||
| .filter((segment) => segment && !segment.startsWith('{')) | ||
| .join('-') | ||
| .split(/[-_]/) | ||
| .filter(Boolean); | ||
| if (words.length === 0) { | ||
| return method.toLowerCase(); | ||
| } | ||
| return words | ||
| .map((word, index) => | ||
| index === 0 | ||
| ? word.charAt(0).toLowerCase() + word.slice(1) | ||
| : word.charAt(0).toUpperCase() + word.slice(1), | ||
| ) | ||
| .join(''); | ||
| }; | ||
| export const parseOpenApi = ( | ||
@@ -94,3 +123,11 @@ paths: OpenAPIV3_1.PathsObject | OpenAPIV3.PathsObject | OpenAPIV2.PathsObject, | ||
| if (bodySchema.type === 'object' && bodySchema.properties) { | ||
| // A request body marked `nullable: true` is rewritten by | ||
| // convertOpenApiSchemaToJsonSchema to `type: ['object', 'null']`, so a strict | ||
| // `=== 'object'` check would skip it and emit a parameterless tool (e.g. v2's | ||
| // promptAgent). Accept an object type whether scalar or in a nullable union. | ||
| const isObjectBody = Array.isArray(bodySchema.type) | ||
| ? bodySchema.type.includes('object') | ||
| : bodySchema.type === 'object'; | ||
| if (isObjectBody && bodySchema.properties) { | ||
| for (const [name, propSchema] of Object.entries(bodySchema.properties)) { | ||
@@ -134,6 +171,6 @@ inputSchema.properties![name] = propSchema; | ||
| tools.push({ | ||
| name: operation.operationId!, | ||
| name: operation.operationId ?? deriveToolName(method, path), | ||
| method: method, | ||
| path: path, | ||
| description: operation.description!, | ||
| description: operation.description ?? operation.summary ?? '', | ||
| inputSchema, | ||
@@ -140,0 +177,0 @@ queryParamsSchema, |
+1
-1
@@ -171,3 +171,3 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; | ||
| get fetch() { | ||
| const fetch = this.config.fetch ?? window['fetch']; | ||
| const fetch = this.config.fetch ?? globalThis.fetch; | ||
@@ -174,0 +174,0 @@ if (!fetch) { |
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
35673
43.32%12
33.33%846
32.19%10
25%