Sign In

@taskade/mcp-openapi-codegen

Package Overview
Dependencies
Maintainers
4
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@taskade/mcp-openapi-codegen - npm Package Compare versions

Comparing version
0.0.2
to
0.0.4
+59
src/normalizeAllOf.test.ts
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",

@@ -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({

@@ -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,

@@ -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) {