diff --git a/packages/openapi-codegen/README.md b/packages/openapi-codegen/README.md index 7e396f2..260cbc6 100644 --- a/packages/openapi-codegen/README.md +++ b/packages/openapi-codegen/README.md @@ -2,7 +2,6 @@ Generate MCP tools from any OpenAPI Schema in seconds. - ## Usage - Install `@taskade/mcp-openapi-codegen` and `@readme/openapi-parser` package: @@ -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, }); ``` @@ -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.", + }, + ], + }; }, + }, }); -``` \ No newline at end of file +``` diff --git a/packages/openapi-codegen/src/parser.test.ts b/packages/openapi-codegen/src/parser.test.ts index b490c12..bc99f42 100644 --- a/packages/openapi-codegen/src/parser.test.ts +++ b/packages/openapi-codegen/src/parser.test.ts @@ -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"], }, }, }, @@ -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"); }); });