Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .changeset/v2-tool-layer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@taskade/mcp-server': minor
'@taskade/mcp-openapi-codegen': patch
---

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.
9 changes: 8 additions & 1 deletion packages/openapi-codegen/src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,17 @@ type CodegenOpts = {
document: OpenAPIV3_1.Document | OpenAPIV3.Document | OpenAPIV2.Document;
isActionsEnabled?: IsActionsEnabledOpt;
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;
};

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

const tools = parseOpenApi(document.paths ?? {});

Expand All @@ -62,7 +69,7 @@ export const codegen = async (opts: CodegenOpts) => {

${runtime}\n

export const setupTools = (server: McpServer, opts: OpenAPIToolRuntimeConfigOpts) => {
export const ${exportName} = (server: McpServer, opts: OpenAPIToolRuntimeConfigOpts) => {

const config = new OpenAPIToolRuntimeConfig(opts);

Expand Down
50 changes: 49 additions & 1 deletion packages/openapi-codegen/src/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ describe('deriveToolName', () => {
expect(deriveToolName('get', '/bundles/{spaceId}/export/zip')).toBe('bundlesExportZip');
});

it('falls back to the HTTP method for a root path', () => {
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');
});
});

Expand All @@ -39,4 +45,46 @@ describe('parseOpenApi name resolution', () => {
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']);
});
});
13 changes: 11 additions & 2 deletions packages/openapi-codegen/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ export type ParsedTool = {
* `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 path. Specs that DO provide `operationId` (e.g. Taskade v1) are unaffected.
* 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
Expand Down Expand Up @@ -118,7 +119,15 @@ export const parseOpenApi = (
),
);

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)) {
inputSchema.properties![name] = propSchema;
}
Expand Down
4 changes: 3 additions & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
},
"scripts": {
"fetch:openapi": "curl -fsSL -o taskade-public.yaml https://www.taskade.com/api/documentation/v1/yaml",
"fetch:openapi:v2": "curl -fsSL -o taskade-public.v2.json https://www.taskade.com/api/documentation/v2/json",
"generate:taskade-mcp-tools": "tsx scripts/gen-taskade-mcp-tools.ts",
"generate:taskade-mcp-tools:v2": "tsx scripts/gen-taskade-mcp-tools-v2.ts",
"build:cli": "tsx scripts/build-cli.ts",
"build": "run-s generate:taskade-mcp-tools build:cli && yarn --cwd=../../ lint:fix",
"build": "run-s generate:taskade-mcp-tools generate:taskade-mcp-tools:v2 build:cli && yarn --cwd=../../ lint:fix",
"start:server": "tsx src/http.ts --watch"
},
"devDependencies": {
Expand Down
91 changes: 91 additions & 0 deletions packages/server/scripts/gen-taskade-mcp-tools-v2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { dereference } from '@readme/openapi-parser';
import { codegen } from '@taskade/mcp-openapi-codegen';
import fs from 'fs';

import { ENABLED_TASKADE_V2_ACTIONS, HUMANIZED_TASKADE_V2_ACTIONS } from '../src/constants.v2';

// Taskade API v2 is a flat RPC API whose operations omit `operationId`; the codegen
// derives tool names from the path (e.g. POST /promptAgent -> "promptAgent").
const deriveName = (p: string) => p.replace(/^\//, '').split('/')[0];

// Why prune before dereferencing: the live v2 spec currently has a broken self-$ref
// inside components.schemas.Field (data.fillerConfig...sourceRef) that makes a full
// dereference throw. The enabled v2 tools don't reference Field, so we keep only the
// allow-listed paths + the component schemas transitively reachable from them. This
// is also what scopes the generated surface to the enabled tools. Remove once the
// upstream spec is fixed and the allow-list grows to need the rest.
const pruneToEnabled = (doc: any) => {
const enabled = new Set<string>(ENABLED_TASKADE_V2_ACTIONS);
const paths: Record<string, unknown> = {};
const matched = new Set<string>();
for (const [p, ms] of Object.entries(doc.paths ?? {})) {
if (enabled.has(deriveName(p))) {
paths[p] = ms;
matched.add(deriveName(p));
}
}

// Fail loudly if an allow-listed action has no matching path (typo, renamed/removed
// upstream op). Without this the action is silently dropped and we ship fewer tools
// than ENABLED_TASKADE_V2_ACTIONS declares, with a green build.
const missing = [...enabled].filter((name) => !matched.has(name));
if (missing.length > 0) {
throw new Error(
`v2 codegen: enabled action(s) have no matching path in taskade-public.v2.json: ${missing.join(', ')}`,
);
}

const allSchemas = doc.components?.schemas ?? {};
const keep = new Set<string>();
const walk = (node: any): void => {
if (!node || typeof node !== 'object') {
return;
}
if (Array.isArray(node)) {
return node.forEach(walk);
}
for (const [k, v] of Object.entries(node)) {
if (k === '$ref' && typeof v === 'string') {
const m = v.match(/#\/components\/schemas\/(.+)$/);
if (m && !keep.has(m[1])) {
keep.add(m[1]);
walk(allSchemas[m[1]]);
}
} else {
walk(v);
}
}
};
walk(paths);

const schemas: Record<string, unknown> = {};
for (const name of keep) {
schemas[name] = allSchemas[name];
}
return { ...doc, paths, components: { ...doc.components, schemas } };
};

const raw = JSON.parse(fs.readFileSync('taskade-public.v2.json', 'utf8'));
let document;
try {
document = await dereference(pruneToEnabled(raw) as never);
} catch (error) {
// The live v2 spec has a known broken self-$ref in components.schemas.Field; pruneToEnabled
// sidesteps it only while the allow-list stays clear of schemas that reach Field. If this
// throws, an enabled tool likely now references the broken node — see the pruneToEnabled note.
throw new Error(
`v2 codegen: failed to dereference the pruned spec (likely the known upstream Field self-$ref reached by a newly enabled action): ${(error as Error).message}`,
);
}

const actions = Object.fromEntries(
Object.entries(HUMANIZED_TASKADE_V2_ACTIONS).map(([name, title]) => [name, { title }]),
);

await codegen({
path: 'src/tools.v2.generated.ts',
document: document as never,
isActionsEnabled: [...ENABLED_TASKADE_V2_ACTIONS],
actions,
exportName: 'setupToolsV2',
});
29 changes: 29 additions & 0 deletions packages/server/src/constants.v2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Taskade Public API v2 (https://www.taskade.com/api/documentation/v2) tools.
//
// v2 is a flat RPC API (POST /operationName) and is currently beta. It is exposed
// as an ADDITIVE layer alongside the v1 tools (constants.ts) — v1 keeps the granular
// task-editing tools v2 does not yet have, and v2 adds capabilities v1 lacks (agent
// chat, webhooks). We start with the highest-value gaps and grow this list as v2
// stabilizes. Names are derived from the path by the codegen (v2 omits operationId).

export const ENABLED_TASKADE_V2_ACTIONS = [
// Agent chat — the capability v1 cannot do at all
'promptAgent',
'listConversations',
'getConversation',
// Real-time events
'subscribeWebhook',
'unsubscribeWebhook',
] as const;

export type TaskadeV2Action = (typeof ENABLED_TASKADE_V2_ACTIONS)[number];

// Keyed to the allow-list so the two cannot drift: a missing, extra, or misspelled
// action here is a compile error rather than a silently untitled tool.
export const HUMANIZED_TASKADE_V2_ACTIONS: Record<TaskadeV2Action, string> = {
promptAgent: 'Chat with an AI Agent',
listConversations: 'List Agent Conversations',
getConversation: 'Get Agent Conversation',
subscribeWebhook: 'Subscribe to a Webhook',
unsubscribeWebhook: 'Unsubscribe from a Webhook',
};
25 changes: 25 additions & 0 deletions packages/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import fetch from 'node-fetch';

import { setupTools } from './tools.generated';
import { setupToolsV2 } from './tools.v2.generated';

type TaskadeServerOpts = {
accessToken: string;
Expand Down Expand Up @@ -188,5 +189,29 @@ export class TaskadeMCPServer extends McpServer {
},
},
});

// Taskade API v2 (beta) — additive layer for capabilities v1 lacks (agent chat,
// webhooks). Same bearer token; base URL is /api/v2. The v1 tools above are
// unaffected.
setupToolsV2(this, {
url: 'https://www.taskade.com/api/v2',
fetch,
headers: {
Authorization: `Bearer ${this.config.accessToken}`,
},
normalizeResponse: {
promptAgent: (response) => {
return {
content: [
{ type: 'text', text: JSON.stringify(response) },
{
type: 'text',
text: "This is the agent's reply. Relay it to the user; manage agents at https://www.taskade.com/agents.",
},
],
};
},
},
});
}
}
Loading
Loading