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
124 changes: 83 additions & 41 deletions packages/openapi-codegen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

Generate MCP tools from any OpenAPI Schema in seconds.


## Usage

- Install `@taskade/mcp-openapi-codegen` and `@readme/openapi-parser` package:
Expand All @@ -16,14 +15,13 @@ npm install --dev @taskade/mcp-openapi-codegen @readme/openapi-parser
```tsx
// scripts/generate-openapi-tools.ts

import { dereference } from '@readme/openapi-parser';
import { codegen } from '@taskade/mcp-openapi-codegen';

import { dereference } from "@readme/openapi-parser";
import { codegen } from "@taskade/mcp-openapi-codegen";

const document = await dereference('taskade-public.yaml');
const document = await dereference("taskade-public.yaml");

await codegen({
path: 'src/tools.generated.ts',
path: "src/tools.generated.ts",
document,
});
```
Expand All @@ -36,58 +34,102 @@ Then run `npx tsx scripts/generate-openapi-tools.ts`

```tsx
// src/server.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

import { setupTools } from "./tools.generated.ts";

const server = new McpServer({
name: 'taskade',
version: '0.0.1',
capabilities: {
resources: {},
tools: {},
}
name: "taskade",
version: "0.0.1",
capabilities: {
resources: {},
tools: {},
},
});

setupTools(server, {
// 1. Base url for the openapi endpoints
url: 'https://www.taskade.com/api/v1',
// 2. Additional headers to include in all requests
headers: {
'X-HEADER': '123'
},
// 3. Override the default fetch method (you most likely need to install `node-fetch` since most MCP clients don't have the Node.js fetch method)
// fetch: nodeFetch
// 1. Base url for the openapi endpoints
url: "https://www.taskade.com/api/v1",
// 2. Additional headers to include in all requests
headers: {
"X-HEADER": "123",
},
// 3. Override the default fetch method (you most likely need to install `node-fetch` since most MCP clients don't have the Node.js fetch method)
// fetch: nodeFetch
});
```


That's it - you're all set!

## Generate Xquik MCP tools

Xquik publishes an OpenAPI schema for its hosted X/Twitter data API. Generate
typed MCP tools from the schema, then provide the API base URL and key header at
runtime:

```tsx
import { dereference } from "@readme/openapi-parser";
import { codegen } from "@taskade/mcp-openapi-codegen";

const document = await dereference("https://xquik.com/openapi.yaml");

await codegen({
path: "src/xquik-tools.generated.ts",
document,
});
```

```tsx
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

import { setupTools } from "./xquik-tools.generated.ts";

const server = new McpServer({
name: "xquik-openapi",
version: "0.0.1",
});

const apiKey = process.env.XQUIK_API_KEY;
if (!apiKey) {
throw new Error("XQUIK_API_KEY is required");
}

setupTools(server, {
url: "https://xquik.com",
headers: {
"x-api-key": apiKey,
},
});
```

Keep `XQUIK_API_KEY` in the process environment. Xquik is a hosted,
closed-source service and an independent third-party service. It is not
affiliated with X Corp.

### Normalizing responses

By default, all responses from APIs are returned to the LLM in text. But there may be situations where you might want to specify custom responses for certain endpoints.
By default, all responses from APIs are returned to the LLM in text. But there may be situations where you might want to specify custom responses for certain endpoints.

For example, you may want to include an additional text response alongside the JSON response of a specific API endpoint:

```tsx
setupTools(this, {
// ...
normalizeResponse: {
folderProjectsGet: (response) => {
return {
content: [
{
type: 'text',
text: JSON.stringify(response),
},
{
type: 'text',
text: 'The url to projects is in the format of: https://www.taskade.com/d/{projectId}. You should link all projects in the response to the user.',
},
],
};
},
setupTools(server, {
// ...
normalizeResponse: {
folderProjectsGet: (response) => {
return {
content: [
{
type: "text",
text: JSON.stringify(response),
},
{
type: "text",
text: "The url to projects is in the format of: https://www.taskade.com/d/{projectId}. You should link all projects in the response to the user.",
},
],
};
},
},
});
```
```
162 changes: 120 additions & 42 deletions packages/openapi-codegen/src/parser.test.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,90 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from "vitest";

import { deriveToolName, parseOpenApi } from './parser';
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');
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("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("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');
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)', () => {
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: {} },
"/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');
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)', () => {
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: {} } },
"/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');
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', () => {
it("falls back to an empty description when neither description nor summary is present", () => {
const tools = parseOpenApi({
'/promptAgent': { post: { responses: {} } },
"/promptAgent": { post: { responses: {} } },
} as never);
expect(tools).toHaveLength(1);
expect(tools[0].description).toBe('');
expect(tools[0].description).toBe("");
});

it('keeps request-body params when the body schema is nullable (API v2 promptAgent)', () => {
it("keeps request-body params when the body schema is nullable (API v2 promptAgent)", () => {
const tools = parseOpenApi({
'/promptAgent': {
"/promptAgent": {
post: {
summary: 'Prompt an agent',
summary: "Prompt an agent",
requestBody: {
content: {
'application/json': {
"application/json": {
schema: {
type: 'object',
type: "object",
nullable: true,
properties: {
spaceId: { type: 'string' },
agentId: { type: 'string' },
prompt: { type: 'string' },
spaceId: { type: "string" },
agentId: { type: "string" },
prompt: { type: "string" },
},
required: ['spaceId', 'agentId', 'prompt'],
required: ["spaceId", "agentId", "prompt"],
},
},
},
Expand All @@ -81,10 +95,74 @@ describe('parseOpenApi name resolution', () => {
} as never);
expect(tools).toHaveLength(1);
expect(Object.keys(tools[0].inputSchema.properties ?? {})).toEqual([
'spaceId',
'agentId',
'prompt',
"spaceId",
"agentId",
"prompt",
]);
expect(tools[0].inputSchema.required).toEqual(['spaceId', 'agentId', 'prompt']);
expect(tools[0].inputSchema.required).toEqual([
"spaceId",
"agentId",
"prompt",
]);
});

it("parses an OpenAPI 3.1 Xquik search endpoint", () => {
const tools = parseOpenApi({
"/api/v1/x/tweets/search": {
get: {
operationId: "searchTweets",
summary: "Search recent public posts",
parameters: [
{
name: "q",
in: "query",
required: true,
schema: { type: "string", minLength: 1 },
},
{
name: "limit",
in: "query",
required: false,
schema: { type: "integer", minimum: 1, maximum: 100 },
},
],
responses: {
"200": {
description: "Search results",
content: {
"application/json": {
schema: {
type: "object",
required: ["data"],
properties: {
data: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
text: { type: "string" },
},
},
},
},
},
},
},
},
},
},
},
} as never);

expect(tools).toHaveLength(1);
expect(tools[0].name).toBe("searchTweets");
expect(tools[0].description).toBe("Search recent public posts");
expect(tools[0].method).toBe("get");
expect(tools[0].path).toBe("/api/v1/x/tweets/search");
expect(tools[0].queryParamsSchema?.required).toEqual(["q"]);
expect(tools[0].queryParamsSchema?.properties).toHaveProperty("q");
expect(tools[0].queryParamsSchema?.properties).toHaveProperty("limit");
expect(tools[0].outputSchema.properties).toHaveProperty("data");
});
});