Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-webhooks-sign.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@taskade/mcp-openapi-codegen': minor
---

New `nameOverrides` codegen/parser option: explicit tool-name overrides keyed by `"<lowercase-method> <path>"` (e.g. `'post /webhooks': 'createWebhook'`). An override beats both `operationId` and the path-derived fallback, disambiguating REST-shaped operations without `operationId` whose derived names would collide (`POST/GET /webhooks`, `GET/DELETE /webhooks/{id}` all derive to `webhooks`). The parser (`parseOpenApi`, `deriveToolName`) is now exported from the package root.
5 changes: 5 additions & 0 deletions .changeset/many-webhooks-crud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@taskade/mcp-server': minor
---

Four new signed-webhook management tools from Taskade API v2 (upstream v6.213.0 "Signed Webhooks"): `createWebhook`, `listWebhooks`, `getWebhook`, `deleteWebhook` — 62 → 66 tools. `createWebhook`'s description warns that the HMAC signing secret is returned exactly once and must be stored securely. The legacy unsigned `subscribeWebhook`/`unsubscribeWebhook` remain available but are deprecated upstream.
6 changes: 6 additions & 0 deletions .changeset/wild-hints-complete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@taskade/mcp-openapi-codegen': patch
'@taskade/mcp-server': patch
---

Generated tools now carry the full MCP annotation set: derived idempotentHint and openWorldHint:false alongside readOnly/destructive hints; per-action overrides supported.
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
[![Add to Cursor](https://img.shields.io/badge/Add_to-Cursor-0098FF?style=flat-square)](cursor://anysphere.cursor-deeplink/mcp/install?name=taskade&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkB0YXNrYWRlL21jcC1zZXJ2ZXIiXSwiZW52Ijp7IlRBU0tBREVfQVBJX0tFWSI6InlvdXItYXBpLWtleS1oZXJlIn19)
[![Install in VS Code](https://img.shields.io/badge/Install_in-VS_Code-007ACC?style=flat-square&logo=visualstudiocode&logoColor=white)](vscode:mcp/install?%7B%22name%22%3A%22taskade%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40taskade%2Fmcp-server%22%5D%2C%22env%22%3A%7B%22TASKADE_API_KEY%22%3A%22%24%7Binput%3Ataskade_api_key%7D%22%7D%7D)

**62 tools** for workspaces, projects, tasks, AI agents, agent chat, webhooks, knowledge bases, templates, media, and sharing — all from your AI client.
**66 tools** for workspaces, projects, tasks, AI agents, agent chat, webhooks, knowledge bases, templates, media, and sharing — all from your AI client.

</div>

Expand All @@ -26,7 +26,7 @@

- [Demo](#demo)
- [Quick Start](#quick-start)
- [Tools (62)](#tools-62)
- [Tools (66)](#tools-66)
- [Why Taskade MCP?](#why-taskade-mcp)
- [Agent Recipes](#agent-recipes)
- [Use Cases](#use-cases)
Expand Down Expand Up @@ -169,7 +169,7 @@ The server starts at `http://localhost:3000` (configure with `PORT` env var). Co

---

## Tools (62)
## Tools (66)

### Workspaces

Expand Down Expand Up @@ -274,8 +274,14 @@ Capabilities the v1 API doesn't have: hold a live conversation with an AI agent,
| `promptAgent` | Send a message to an AI agent and get its reply |
| `listConversations` | List an agent's conversations |
| `getConversation` | Get a conversation's messages |
| `subscribeWebhook` | Subscribe to real-time Taskade event webhooks |
| `unsubscribeWebhook` | Remove a webhook subscription |
| `createWebhook` | Register a signed (HMAC) webhook for one or more events |
| `listWebhooks` | List your registered signed webhooks |
| `getWebhook` | Get a signed webhook by id |
| `deleteWebhook` | Delete a signed webhook |
| `subscribeWebhook` | Subscribe to event webhooks (legacy, unsigned — prefer `createWebhook`) |
| `unsubscribeWebhook` | Remove a legacy webhook subscription |

> **Warning:** `createWebhook` returns the HMAC signing secret exactly **once** — it cannot be retrieved again (not even via `getWebhook`). Store it securely before doing anything else; you need it to verify the signatures on incoming deliveries.

---

Expand All @@ -293,7 +299,7 @@ Taskade MCP gives your AI assistant **access to your workspace** — projects, t
┌──────────────────────────────────┐
│ Taskade MCP Server │
│ (62 tools, 8 categories) │
│ (66 tools, 8 categories) │
└──────────────────────────────────┘
↓ ↓ ↓
folderCreateAgent agentKnowledge agentPublicAccess
Expand All @@ -320,7 +326,7 @@ Taskade MCP gives your AI assistant **access to your workspace** — projects, t

### Why Taskade MCP Over Other MCP Servers?

Taskade is the only MCP server that includes **AI agent management** (create, train, deploy agents), **knowledge base training** (attach docs, projects, media), and **OpenAPI codegen** (generate MCP tools from any API spec). 62 tools across 8 categories.
Taskade is the only MCP server that includes **AI agent management** (create, train, deploy agents), **knowledge base training** (attach docs, projects, media), and **OpenAPI codegen** (generate MCP tools from any API spec). 66 tools across 8 categories.

---

Expand Down
115 changes: 115 additions & 0 deletions packages/openapi-codegen/src/codegen.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { OpenAPIV3 } from 'openapi-types';
import { describe, expect, it } from 'vitest';

import { codegen } from './codegen';

const document = {
openapi: '3.0.0',
info: { title: 'Tiny API', version: '1.0.0' },
paths: {
'/things': {
get: { operationId: 'thingList', description: 'List things', responses: {} },
post: { operationId: 'thingCreate', description: 'Create a thing', responses: {} },
},
'/things/{thingId}': {
put: { operationId: 'thingUpdate', description: 'Update a thing', responses: {} },
delete: { operationId: 'thingDelete', description: 'Delete a thing', responses: {} },
},
},
} as OpenAPIV3.Document;

/**
* Extracts the annotation hints for a named tool out of the generated
* (prettier-formatted) source.
*/
const hintsFor = (output: string, toolName: string) => {
const chunk = output
.split('server.tool(')
.find((part) => part.trimStart().startsWith(`"${toolName}"`));
expect(chunk, `generated output contains tool ${toolName}`).toBeDefined();

const hints: Record<string, boolean> = {};
for (const key of ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']) {
const match = chunk?.match(new RegExp(`${key}:\\s*(true|false)`));
expect(match, `${toolName} carries ${key}`).toBeTruthy();
hints[key] = match?.[1] === 'true';
}
return hints;
};

describe('codegen derived annotations', () => {
it('derives the full MCP hint set from the HTTP method', async () => {
const output = await codegen({ document });

expect(hintsFor(output, 'thingList')).toEqual({
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
});
expect(hintsFor(output, 'thingCreate')).toEqual({
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
});
expect(hintsFor(output, 'thingUpdate')).toEqual({
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
});
expect(hintsFor(output, 'thingDelete')).toEqual({
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
});
});

it('lets a per-action config override the LLM-visible description (spec text loses)', async () => {
// Pins the mechanism used to fix misleading upstream descriptions (e.g. v2's
// getWebhook/deleteWebhook telling callers to pre-URL-encode the id while the
// runtime already encodes path params).
const output = await codegen({
document,
actions: {
thingDelete: { description: 'Delete a thing by raw id — do NOT URL-encode it.' },
},
});

const chunkFor = (toolName: string) =>
output.split('server.tool(').find((part) => part.trimStart().startsWith(`"${toolName}"`));

// Overridden tool carries the override, not the spec description.
expect(chunkFor('thingDelete')).toContain('Delete a thing by raw id — do NOT URL-encode it.');
expect(chunkFor('thingDelete')).not.toContain('"Delete a thing"');
// Tools without an override keep the spec description.
expect(chunkFor('thingList')).toContain('"List things"');
});

it('lets a per-action config override any derived hint, including to false', async () => {
const output = await codegen({
document,
actions: {
// A POST that is actually idempotent (e.g. taskComplete-style actions).
thingCreate: { idempotentHint: true },
// An explicit `false` override must beat a derived `true`.
thingDelete: { destructiveHint: false },
},
});

expect(hintsFor(output, 'thingCreate')).toEqual({
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
});
expect(hintsFor(output, 'thingDelete')).toEqual({
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
});
});
});
51 changes: 45 additions & 6 deletions packages/openapi-codegen/src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,32 @@ import { OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
import path from 'path';
import prettier from 'prettier';

import { ParsedTool, parseOpenApi } from './parser';
import { ParsedTool, parseOpenApi, ParseOpenApiOpts } from './parser';

type IsActionsEnabledOpt = Array<string> | ((actionName: string) => boolean);

type ActionConfig = Partial<{
title: string;
description: string;
normalizer: (args: any) => any;
/**
* Explicit MCP annotation overrides. When set, they beat the values derived
* from the HTTP method (e.g. a POST action that is actually idempotent can
* set `idempotentHint: true`).
*/
readOnlyHint: boolean;
destructiveHint: boolean;
idempotentHint: boolean;
openWorldHint: boolean;
}>;

const ANNOTATION_HINT_KEYS = [
'readOnlyHint',
'destructiveHint',
'idempotentHint',
'openWorldHint',
] as const;

const isActionsEnabled = (isActionsEnabledOpt: IsActionsEnabledOpt, actionName: string) => {
if (typeof isActionsEnabledOpt === 'function') {
return isActionsEnabledOpt(actionName);
Expand Down Expand Up @@ -48,13 +64,20 @@ type CodegenOpts = {
* the first without an export-name collision.
*/
exportName?: string;
/**
* Explicit tool-name overrides keyed by `"<lowercase-method> <path>"` — see
* `ParseOpenApiOpts.nameOverrides`. Use for operations without `operationId`
* whose path-derived names would collide (REST siblings like `GET /webhooks`
* and `GET /webhooks/{id}`).
*/
nameOverrides?: ParseOpenApiOpts['nameOverrides'];
};

export const codegen = async (opts: CodegenOpts) => {
const { document, path: outputPath } = opts;
const exportName = opts.exportName ?? 'setupTools';

const tools = parseOpenApi(document.paths ?? {});
const tools = parseOpenApi(document.paths ?? {}, { nameOverrides: opts.nameOverrides });

const runtime = fs.readFileSync(path.join(import.meta.dirname, 'runtime.ts'), 'utf8');

Expand All @@ -81,23 +104,39 @@ export const codegen = async (opts: CodegenOpts) => {
}

// 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.
// DELETE is destructive, GET/HEAD/PUT/DELETE are idempotent per HTTP
// semantics (POST is not). openWorldHint is always false: this server only
// talks to the Taskade API — a closed world. A human-friendly title and
// explicit hint overrides can be supplied via opts.actions.
const method = tool.method.toUpperCase();
const annotations: Record<string, any> = {
readOnlyHint: method === 'GET' || method === 'HEAD',
destructiveHint: method === 'DELETE',
idempotentHint: ['GET', 'HEAD', 'PUT', 'DELETE'].includes(method),
openWorldHint: false,
};

const actionTitle = opts.actions?.[tool.name]?.title;
const actionConfig = opts.actions?.[tool.name];

// Explicit per-action overrides beat derived values. Undefined-checked so
// an explicit `false` override wins too.
for (const hint of ANNOTATION_HINT_KEYS) {
const override = actionConfig?.[hint];
if (override !== undefined) {
annotations[hint] = override;
}
}

const actionTitle = actionConfig?.title;
if (actionTitle) {
annotations.title = actionTitle;
}

const description = opts.actions?.[tool.name]?.description ?? tool.description;
const description = actionConfig?.description ?? tool.description;

const toolArgs = [`"${tool.name}"`, `"${description}"`, generateToolInputFromParsedTool(tool)];

// annotations always carry read-only/destructive hints, so always include them
// annotations always carry the derived hint set, so always include them
toolArgs.push(JSON.stringify(annotations));

toolArgs.push(`async (args) => {
Expand Down
1 change: 1 addition & 0 deletions packages/openapi-codegen/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './codegen';
export * from './parser';
65 changes: 65 additions & 0 deletions packages/openapi-codegen/src/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ describe('deriveToolName', () => {
expect(deriveToolName('get', '/')).toBe('get');
expect(deriveToolName('POST', '/{id}')).toBe('post');
});

it('collides for REST siblings: {param} segments dropped, method ignored (the nameOverrides hazard)', () => {
// Pins the hazard that motivates nameOverrides: without an override, all four
// signed-webhook CRUD ops would emit the same tool name.
expect(deriveToolName('post', '/webhooks')).toBe('webhooks');
expect(deriveToolName('get', '/webhooks')).toBe('webhooks');
expect(deriveToolName('get', '/webhooks/{id}')).toBe('webhooks');
expect(deriveToolName('delete', '/webhooks/{id}')).toBe('webhooks');
});
});

describe('parseOpenApi name resolution', () => {
Expand Down Expand Up @@ -87,4 +96,60 @@ describe('parseOpenApi name resolution', () => {
]);
expect(tools[0].inputSchema.required).toEqual(['spaceId', 'agentId', 'prompt']);
});

it('applies nameOverrides keyed by "<lowercase-method> <path>" to disambiguate REST siblings', () => {
const tools = parseOpenApi(
{
'/webhooks': {
post: { summary: 'Register a signed webhook', responses: {} },
get: { summary: 'List signed webhooks', responses: {} },
},
'/webhooks/{id}': {
get: { summary: 'Get a signed webhook', responses: {} },
delete: { summary: 'Delete a signed webhook', responses: {} },
},
} as never,
{
nameOverrides: {
'post /webhooks': 'createWebhook',
'get /webhooks': 'listWebhooks',
'get /webhooks/{id}': 'getWebhook',
'delete /webhooks/{id}': 'deleteWebhook',
},
},
);
expect(tools.map((t) => t.name).sort()).toEqual([
'createWebhook',
'deleteWebhook',
'getWebhook',
'listWebhooks',
]);
});

it('without nameOverrides, REST siblings all fall back to the same derived name (collision pinned)', () => {
const tools = parseOpenApi({
'/webhooks': {
post: { responses: {} },
get: { responses: {} },
},
'/webhooks/{id}': {
get: { responses: {} },
delete: { responses: {} },
},
} as never);
expect(tools.map((t) => t.name)).toEqual(['webhooks', 'webhooks', 'webhooks', 'webhooks']);
});

it('an explicit override beats operationId; unkeyed operations are unaffected', () => {
const tools = parseOpenApi(
{
'/projects': {
post: { operationId: 'projectCreate', responses: {} },
get: { operationId: 'projectsList', responses: {} },
},
} as never,
{ nameOverrides: { 'post /projects': 'createProject' } },
);
expect(tools.map((t) => t.name).sort()).toEqual(['createProject', 'projectsList']);
});
});
Loading