diff --git a/scripts/tools.json b/scripts/tools.json index fde8a1c..aeab7e5 100644 --- a/scripts/tools.json +++ b/scripts/tools.json @@ -1938,160 +1938,375 @@ "name": "create-object", "description": "Create object\nError Responses:\n400: Bad request\n401: Unauthorized\n429: Rate limit exceeded\n500: Internal server error", "inputSchema": { - "$defs": {}, "type": "object", "properties": { "space_id": { "type": "string", - "description": "The ID of the space in which to create the object; must be retrieved from ListSpaces endpoint" + "description": "The ID of the space to create the object in" }, - "body": { + "type_key": { "type": "string", - "description": "The body of the object" + "description": "The key of the object type (e.g. 'page', 'task', 'bookmark')" + }, + "name": { + "description": "The name of the object", + "type": "string" + }, + "body": { + "description": "The initial body text or markdown of the object", + "type": "string" }, "icon": { - "type": "object", - "description": "The icon of the object, or null if the object has no icon", - "properties": { - "emoji": { - "type": "string", - "description": "The emoji of the icon" - }, - "format": { - "type": "string", - "description": "The format of the icon", - "enum": [ - "emoji" + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "emoji": { + "type": "string", + "description": "Emoji character for the icon (e.g. '📄', '🚀')" + }, + "format": { + "description": "Icon format ('emoji')", + "type": "string", + "const": "emoji" + } + }, + "required": [ + "emoji" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "file": { + "type": "string", + "description": "File ID of the icon image" + }, + "format": { + "type": "string", + "const": "file", + "description": "Icon format ('file')" + } + }, + "required": [ + "file", + "format" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the icon (e.g. 'alarm', 'archive', 'star')" + }, + "color": { + "description": "Color of the icon", + "type": "string" + }, + "format": { + "description": "Icon format ('icon')", + "type": "string", + "const": "icon" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + } ] + }, + { + "type": "null" } - }, - "additionalProperties": true + ], + "description": "The icon of the object, or null to remove the icon" }, - "name": { - "type": "string", - "description": "The name of the object" + "template_id": { + "description": "The ID of the template to instantiate from", + "type": "string" }, "properties": { + "description": "Object properties to set", "type": "array", - "description": "The properties to set on the object; see ListTypes or GetType endpoints for linked properties", "items": { - "type": "object", - "properties": { - "key": { - "type": "string", - "description": "The key of the property", - "examples": [ - "last_modified_date" - ] - }, - "text": { - "type": "string", - "description": "The text value, if applicable", - "examples": [ - "Some text..." - ] + "oneOf": [ + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'description', 'notes')" + }, + "format": { + "type": "string", + "const": "text" + }, + "text": { + "type": "string", + "description": "The text value of the property" + } + }, + "required": [ + "key", + "format", + "text" + ], + "additionalProperties": false }, - "number": { - "type": "number", - "description": "The number value, if applicable", - "examples": [ - 42 - ] + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'priority', 'count')" + }, + "format": { + "type": "string", + "const": "number" + }, + "number": { + "type": "number", + "description": "The numerical value of the property" + } + }, + "required": [ + "key", + "format", + "number" + ], + "additionalProperties": false }, - "select": { - "type": "string", - "description": "The selected tag id, if applicable", - "examples": [ - "tag_id" - ] + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'status')" + }, + "format": { + "type": "string", + "const": "select" + }, + "select": { + "type": "string", + "description": "Selected tag key or ID" + } + }, + "required": [ + "key", + "format", + "select" + ], + "additionalProperties": false }, - "multi_select": { - "type": "array", - "description": "The selected tag ids, if applicable", - "items": { - "type": "string" + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'tags')" + }, + "format": { + "type": "string", + "const": "multi_select" + }, + "multi_select": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of selected tag keys or IDs" + } }, - "examples": [ - [ - "tag_id" - ] - ] + "required": [ + "key", + "format", + "multi_select" + ], + "additionalProperties": false }, - "date": { - "type": "string", - "description": "The date value in ISO 8601 format, if applicable", - "examples": [ - "2025-02-14T12:34:56Z" - ] + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'due_date')" + }, + "format": { + "type": "string", + "const": "date" + }, + "date": { + "type": "string", + "description": "Date in RFC3339 or ISO 8601 format (e.g. '2026-08-21T12:00:00Z' or '2026-08-21')" + } + }, + "required": [ + "key", + "format", + "date" + ], + "additionalProperties": false }, - "files": { - "type": "array", - "description": "The file ids, if applicable", - "items": { - "type": "string" + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'attachments')" + }, + "format": { + "type": "string", + "const": "files" + }, + "files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of file IDs" + } }, - "examples": [ - [ - "['file_id']" - ] - ] + "required": [ + "key", + "format", + "files" + ], + "additionalProperties": false }, - "checkbox": { - "type": "boolean", - "description": "The checkbox value, if applicable", - "examples": [ - true - ] + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'done', 'archived')" + }, + "format": { + "type": "string", + "const": "checkbox" + }, + "checkbox": { + "type": "boolean", + "description": "Boolean checkbox state (true/false)" + } + }, + "required": [ + "key", + "format", + "checkbox" + ], + "additionalProperties": false }, - "url": { - "type": "string", - "description": "The url value, if applicable", - "examples": [ - "https://example.com" - ] + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'website', 'source')" + }, + "format": { + "type": "string", + "const": "url" + }, + "url": { + "type": "string", + "description": "URL string" + } + }, + "required": [ + "key", + "format", + "url" + ], + "additionalProperties": false }, - "email": { - "type": "string", - "description": "The email value, if applicable", - "examples": [ - "example@example.com" - ] + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'email', 'contact')" + }, + "format": { + "type": "string", + "const": "email" + }, + "email": { + "type": "string", + "description": "Email address" + } + }, + "required": [ + "key", + "format", + "email" + ], + "additionalProperties": false }, - "phone": { - "type": "string", - "description": "The phone number value, if applicable", - "examples": [ - "+1234567890" - ] + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'phone')" + }, + "format": { + "type": "string", + "const": "phone" + }, + "phone": { + "type": "string", + "description": "Phone number string" + } + }, + "required": [ + "key", + "format", + "phone" + ], + "additionalProperties": false }, - "objects": { - "type": "array", - "description": "The object ids, if applicable", - "items": { - "type": "string" + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'relations', 'links')" + }, + "format": { + "type": "string", + "const": "objects" + }, + "objects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of linked object IDs" + } }, - "examples": [ - [ - "['object_id']" - ] - ] + "required": [ + "key", + "format", + "objects" + ], + "additionalProperties": false } - } + ] } - }, - "template_id": { - "type": "string", - "description": "The id of the template to use" - }, - "type_key": { - "type": "string", - "description": "The key of the type of object to create" } }, "required": [ "space_id", "type_key" - ] + ], + "additionalProperties": {} }, "outputSchema": { "type": "object", @@ -3053,160 +3268,375 @@ "name": "update-object", "description": "Update object\nError Responses:\n400: Bad request\n401: Unauthorized\n404: Resource not found\n410: Resource deleted\n429: Rate limit exceeded\n500: Internal server error", "inputSchema": { - "$defs": {}, "type": "object", "properties": { "space_id": { "type": "string", - "description": "The ID of the space in which the object exists; must be retrieved from ListSpaces endpoint" + "description": "The ID of the space containing the object" }, "object_id": { "type": "string", - "description": "The ID of the object to update; must be retrieved from ListObjects, SearchSpace or GlobalSearch endpoints or obtained from response context" + "description": "The ID of the object to update" + }, + "name": { + "description": "The updated name of the object", + "type": "string" + }, + "markdown": { + "description": "The updated markdown body of the object", + "type": "string" }, "icon": { - "type": "object", - "description": "The icon of the object, or null if the object has no icon", - "properties": { - "emoji": { - "type": "string", - "description": "The emoji of the icon" - }, - "format": { - "type": "string", - "description": "The format of the icon", - "enum": [ - "emoji" + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "emoji": { + "type": "string", + "description": "Emoji character for the icon (e.g. '📄', '🚀')" + }, + "format": { + "description": "Icon format ('emoji')", + "type": "string", + "const": "emoji" + } + }, + "required": [ + "emoji" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "file": { + "type": "string", + "description": "File ID of the icon image" + }, + "format": { + "type": "string", + "const": "file", + "description": "Icon format ('file')" + } + }, + "required": [ + "file", + "format" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the icon (e.g. 'alarm', 'archive', 'star')" + }, + "color": { + "description": "Color of the icon", + "type": "string" + }, + "format": { + "description": "Icon format ('icon')", + "type": "string", + "const": "icon" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + } ] + }, + { + "type": "null" } - }, - "additionalProperties": true - }, - "markdown": { - "type": "string", - "description": "The updated body of the object" + ], + "description": "The icon of the object, or null to remove the icon" }, - "name": { - "type": "string", - "description": "The name of the object" + "type_key": { + "description": "The key of the type of object", + "type": "string" }, "properties": { + "description": "Object properties to set/update", "type": "array", - "description": "The properties to set for the object; see ListTypes or GetType endpoints for linked properties", "items": { - "type": "object", - "properties": { - "key": { - "type": "string", - "description": "The key of the property", - "examples": [ - "last_modified_date" - ] - }, - "text": { - "type": "string", - "description": "The text value, if applicable", - "examples": [ - "Some text..." - ] + "oneOf": [ + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'description', 'notes')" + }, + "format": { + "type": "string", + "const": "text" + }, + "text": { + "type": "string", + "description": "The text value of the property" + } + }, + "required": [ + "key", + "format", + "text" + ], + "additionalProperties": false }, - "number": { - "type": "number", - "description": "The number value, if applicable", - "examples": [ - 42 - ] + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'priority', 'count')" + }, + "format": { + "type": "string", + "const": "number" + }, + "number": { + "type": "number", + "description": "The numerical value of the property" + } + }, + "required": [ + "key", + "format", + "number" + ], + "additionalProperties": false }, - "select": { - "type": "string", - "description": "The selected tag id, if applicable", - "examples": [ - "tag_id" - ] + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'status')" + }, + "format": { + "type": "string", + "const": "select" + }, + "select": { + "type": "string", + "description": "Selected tag key or ID" + } + }, + "required": [ + "key", + "format", + "select" + ], + "additionalProperties": false }, - "multi_select": { - "type": "array", - "description": "The selected tag ids, if applicable", - "items": { - "type": "string" + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'tags')" + }, + "format": { + "type": "string", + "const": "multi_select" + }, + "multi_select": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of selected tag keys or IDs" + } }, - "examples": [ - [ - "tag_id" - ] - ] + "required": [ + "key", + "format", + "multi_select" + ], + "additionalProperties": false }, - "date": { - "type": "string", - "description": "The date value in ISO 8601 format, if applicable", - "examples": [ - "2025-02-14T12:34:56Z" - ] + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'due_date')" + }, + "format": { + "type": "string", + "const": "date" + }, + "date": { + "type": "string", + "description": "Date in RFC3339 or ISO 8601 format (e.g. '2026-08-21T12:00:00Z' or '2026-08-21')" + } + }, + "required": [ + "key", + "format", + "date" + ], + "additionalProperties": false }, - "files": { - "type": "array", - "description": "The file ids, if applicable", - "items": { - "type": "string" + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'attachments')" + }, + "format": { + "type": "string", + "const": "files" + }, + "files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of file IDs" + } }, - "examples": [ - [ - "['file_id']" - ] - ] + "required": [ + "key", + "format", + "files" + ], + "additionalProperties": false }, - "checkbox": { - "type": "boolean", - "description": "The checkbox value, if applicable", - "examples": [ - true - ] + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'done', 'archived')" + }, + "format": { + "type": "string", + "const": "checkbox" + }, + "checkbox": { + "type": "boolean", + "description": "Boolean checkbox state (true/false)" + } + }, + "required": [ + "key", + "format", + "checkbox" + ], + "additionalProperties": false }, - "url": { - "type": "string", - "description": "The url value, if applicable", - "examples": [ - "https://example.com" - ] + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'website', 'source')" + }, + "format": { + "type": "string", + "const": "url" + }, + "url": { + "type": "string", + "description": "URL string" + } + }, + "required": [ + "key", + "format", + "url" + ], + "additionalProperties": false }, - "email": { - "type": "string", - "description": "The email value, if applicable", - "examples": [ - "example@example.com" - ] + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'email', 'contact')" + }, + "format": { + "type": "string", + "const": "email" + }, + "email": { + "type": "string", + "description": "Email address" + } + }, + "required": [ + "key", + "format", + "email" + ], + "additionalProperties": false }, - "phone": { - "type": "string", - "description": "The phone number value, if applicable", - "examples": [ - "+1234567890" - ] + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'phone')" + }, + "format": { + "type": "string", + "const": "phone" + }, + "phone": { + "type": "string", + "description": "Phone number string" + } + }, + "required": [ + "key", + "format", + "phone" + ], + "additionalProperties": false }, - "objects": { - "type": "array", - "description": "The object ids, if applicable", - "items": { - "type": "string" + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The property key (e.g. 'relations', 'links')" + }, + "format": { + "type": "string", + "const": "objects" + }, + "objects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of linked object IDs" + } }, - "examples": [ - [ - "['object_id']" - ] - ] + "required": [ + "key", + "format", + "objects" + ], + "additionalProperties": false } - } + ] } - }, - "type_key": { - "type": "string", - "description": "The key of the type of object to set" } }, "required": [ "space_id", "object_id" - ] + ], + "additionalProperties": {} }, "outputSchema": { "type": "object", diff --git a/src/index.ts b/src/index.ts index bfb0660..8e47913 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; export { HttpClient } from "./client/http-client"; export { OpenAPIToMCPConverter } from "./openapi/parser"; +export * from "./tools"; diff --git a/src/mcp/__tests__/proxy.test.ts b/src/mcp/__tests__/proxy.test.ts index 6acebc6..dfdbe8f 100644 --- a/src/mcp/__tests__/proxy.test.ts +++ b/src/mcp/__tests__/proxy.test.ts @@ -2,11 +2,21 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { Headers } from "node-fetch"; import { OpenAPIV3 } from "openapi-types"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { HttpClient } from "../../client/http-client"; +import { HttpClient, HttpClientError } from "../../client/http-client"; import { MCPProxy } from "../proxy"; // Mock the dependencies -vi.mock("../../client/http-client"); +vi.mock("../../client/http-client", async (importOriginal) => { + const actual = await importOriginal(); + const MockHttpClient = vi.fn().mockImplementation(function (this: any) { + this.executeOperation = MockHttpClient.prototype.executeOperation; + }); + MockHttpClient.prototype.executeOperation = vi.fn(); + return { + ...actual, + HttpClient: MockHttpClient, + }; +}); vi.mock("@modelcontextprotocol/sdk/server/index.js"); describe("MCPProxy", () => { @@ -125,6 +135,149 @@ describe("MCPProxy", () => { content: [{ type: "text", text: JSON.stringify({ message: "success" }) }], }); }); + + it("should validate and execute tool calls using ToolOverrides", async () => { + const executeMock = (HttpClient.prototype.executeOperation as ReturnType).mockResolvedValue( + mockSuccessResponse, + ); + + (proxy as any).openApiLookup = { + "API-create-object": { + operationId: "create_object", + responses: { "200": { description: "Success" } }, + method: "post", + path: "/spaces/{space_id}/objects", + }, + }; + + const [, callToolHandler] = getHandlers(proxy); + const result = await callToolHandler({ + params: { + name: "API-create-object", + arguments: { + space_id: "space_123", + type_key: "page", + properties: [ + { + key: "status", + format: "select", + select: "tag_1", + text: "extraneous dummy", + }, + ], + }, + }, + }); + + expect(result.content[0].text).toBe(JSON.stringify({ message: "success" })); + expect(executeMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + space_id: "space_123", + type_key: "page", + properties: [{ key: "status", format: "select", select: "tag_1" }], + }), + ); + }); + + it("should reject invalid tool calls using ToolOverrides with structured error", async () => { + (proxy as any).openApiLookup = { + "API-create-object": { + operationId: "create_object", + responses: { "200": { description: "Success" } }, + method: "post", + path: "/spaces/{space_id}/objects", + }, + }; + + const [, callToolHandler] = getHandlers(proxy); + const result = await callToolHandler({ + params: { + name: "API-create-object", + arguments: { + // Missing space_id and type_key + name: "Invalid", + }, + }, + }); + + expect(result.isError).toBe(true); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.status).toBe("error"); + expect(parsed.details).toBeDefined(); + }); + + it("should validate and execute API-update-object tool calls using ToolOverrides", async () => { + const executeMock = (HttpClient.prototype.executeOperation as ReturnType).mockResolvedValue( + mockSuccessResponse, + ); + + (proxy as any).openApiLookup = { + "API-update-object": { + operationId: "update_object", + responses: { "200": { description: "Success" } }, + method: "patch", + path: "/spaces/{space_id}/objects/{object_id}", + }, + }; + + const [, callToolHandler] = getHandlers(proxy); + const result = await callToolHandler({ + params: { + name: "API-update-object", + arguments: { + space_id: "space_123", + object_id: "obj_456", + name: "Updated Name", + markdown: "Updated content", + icon: null, + }, + }, + }); + + expect(result.content[0].text).toBe(JSON.stringify({ message: "success" })); + expect(executeMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + space_id: "space_123", + object_id: "obj_456", + name: "Updated Name", + markdown: "Updated content", + icon: null, + }), + ); + }); + + it("should handle HttpClientError and return formatted error with isError: true", async () => { + (HttpClient.prototype.executeOperation as ReturnType).mockRejectedValue( + new HttpClientError("Bad Request", 400, { message: "Invalid payload from backend" }), + ); + + (proxy as any).openApiLookup = { + "API-create-object": { + operationId: "create_object", + responses: { "200": { description: "Success" } }, + method: "post", + path: "/spaces/{space_id}/objects", + }, + }; + + const [, callToolHandler] = getHandlers(proxy); + const result = await callToolHandler({ + params: { + name: "API-create-object", + arguments: { + space_id: "space_123", + type_key: "page", + }, + }, + }); + + expect(result.isError).toBe(true); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.status).toBe("error"); + expect(parsed.message).toBe("Invalid payload from backend"); + }); }); describe("getContentType", () => { diff --git a/src/mcp/proxy.ts b/src/mcp/proxy.ts index 537ae22..d0b5b9e 100644 --- a/src/mcp/proxy.ts +++ b/src/mcp/proxy.ts @@ -6,6 +6,7 @@ import { Headers } from "node-fetch"; import { OpenAPIV3 } from "openapi-types"; import { HttpClient, HttpClientError } from "../client/http-client"; import { OpenAPIToMCPConverter } from "../openapi/parser"; +import { ToolOverrides } from "../tools"; import { determineBaseUrl } from "../utils/base-url"; type PathItemObject = OpenAPIV3.PathItemObject & { @@ -46,7 +47,17 @@ export class MCPProxy { const converter = new OpenAPIToMCPConverter(openApiSpec); const { tools, openApiLookup } = converter.convertToMCPTools(); this.tools = tools; - this.openApiLookup = openApiLookup; + + // Normalize openApiLookup to index both full and truncated names (<= 64 chars) + const normalizedLookup: Record = {}; + for (const [key, val] of Object.entries(openApiLookup)) { + normalizedLookup[key] = val; + const truncated = this.truncateToolName(key); + if (truncated !== key) { + normalizedLookup[truncated] = val; + } + } + this.openApiLookup = normalizedLookup; this.setupHandlers(); } @@ -84,9 +95,32 @@ export class MCPProxy { throw new Error(`Method ${name} not found`); } + // Validate with ToolOverrides if defined + let validatedParams: Record | undefined = params; + if (name in ToolOverrides) { + const parseResult = ToolOverrides[name].zodSchema.safeParse(params); + if (!parseResult.success) { + console.error("Validation error in tool call:", parseResult.error.format()); + return { + content: [ + { + type: "text", + text: JSON.stringify({ + status: "error", + error: `Validation failed for tool '${name}'`, + details: parseResult.error.issues, + }), + }, + ], + isError: true, + }; + } + validatedParams = parseResult.data as Record; + } + try { // Execute the operation - const response = await this.httpClient.executeOperation(operation, params); + const response = await this.httpClient.executeOperation(operation, validatedParams); // Convert response to MCP format return { @@ -112,6 +146,7 @@ export class MCPProxy { }), }, ], + isError: true, }; } throw error; diff --git a/src/openapi/__tests__/parser.test.ts b/src/openapi/__tests__/parser.test.ts index 70cbafe..2fcfbf0 100644 --- a/src/openapi/__tests__/parser.test.ts +++ b/src/openapi/__tests__/parser.test.ts @@ -1592,4 +1592,33 @@ describe("OpenAPIToMCPConverter - Additional Complex Tests", () => { verifyTools(tools, expected.tools); expect(openApiLookup).toEqual(expected.openApiLookup); }); + + describe("ToolOverrides Integration", () => { + it("should inject ToolOverrides schema for create_object operation", () => { + const specWithCreate: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Anytype API", version: "1.0.0" }, + paths: { + "/v1/spaces/{space_id}/objects": { + post: { + operationId: "create_object", + summary: "Create object", + responses: { "200": { description: "Success" } }, + }, + }, + }, + }; + + const converter = new OpenAPIToMCPConverter(specWithCreate); + const { tools } = converter.convertToMCPTools(); + + expect(tools.API).toBeDefined(); + const method = tools.API.methods.find((m) => m.name === "create-object"); + expect(method).toBeDefined(); + expect(method?.inputSchema.properties).toHaveProperty("space_id"); + expect(method?.inputSchema.properties).toHaveProperty("type_key"); + expect(method?.inputSchema.properties).toHaveProperty("properties"); + expect(method?.inputSchema).not.toHaveProperty("$schema"); + }); + }); }); diff --git a/src/openapi/parser.ts b/src/openapi/parser.ts index 0fad43d..ca8e863 100644 --- a/src/openapi/parser.ts +++ b/src/openapi/parser.ts @@ -2,6 +2,7 @@ import type { Tool } from "@anthropic-ai/sdk/resources/messages/messages"; import type { JSONSchema7 as IJsonSchema } from "json-schema"; import type { ChatCompletionTool } from "openai/resources/chat/completions"; import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; +import { ToolOverrides } from "../tools"; type NewToolMethod = { name: string; @@ -420,6 +421,11 @@ export class OpenAPIToMCPConverter { method: string, path: string, ): IJsonSchema & { type: "object" } { + const fullToolName = `API-${operation.operationId?.replaceAll("_", "-")}`; + if (fullToolName in ToolOverrides) { + return ToolOverrides[fullToolName].inputSchema; + } + const schema: IJsonSchema & { type: "object" } = { type: "object", properties: {}, @@ -544,81 +550,88 @@ export class OpenAPIToMCPConverter { } const methodName = operation.operationId; + const fullToolName = `API-${methodName.replaceAll("_", "-")}`; - const inputSchema: IJsonSchema & { type: "object" } = { - $defs: {}, // Omit this.convertComponentsToJsonSchema() to reduce definition size - type: "object", - properties: {}, - required: [], - }; + let inputSchema: IJsonSchema & { type: "object" }; - // Handle parameters (path, query, header, cookie) - if (operation.parameters) { - for (const param of operation.parameters) { - const paramObj = this.resolveParameter(param); - if (paramObj && paramObj.schema) { - // do not include Anytype-Version in the input schema, it's set in http client header by proxy - if (paramObj.name === "Anytype-Version") { - continue; - } - const schema = this.convertOpenApiSchemaToJsonSchema(paramObj.schema, new Set(), true); - // Merge parameter-level description if available - if (paramObj.description) { - schema.description = paramObj.description; - } - inputSchema.properties![paramObj.name] = schema; - if (paramObj.required) { - inputSchema.required!.push(paramObj.name); - } - } - } - } + if (fullToolName in ToolOverrides) { + inputSchema = ToolOverrides[fullToolName].inputSchema; + } else { + inputSchema = { + $defs: {}, // Omit this.convertComponentsToJsonSchema() to reduce definition size + type: "object", + properties: {}, + required: [], + }; - // Handle requestBody - if (operation.requestBody) { - const bodyObj = this.resolveRequestBody(operation.requestBody); - if (bodyObj?.content) { - // Handle multipart/form-data for file uploads - // We convert the multipart/form-data schema to a JSON schema and we require - // that the user passes in a string for each file that points to the local file - if (bodyObj.content["multipart/form-data"]?.schema) { - const formSchema = this.convertOpenApiSchemaToJsonSchema( - bodyObj.content["multipart/form-data"].schema, - new Set(), - true, - ); - if (formSchema.type === "object" && formSchema.properties) { - for (const [name, propSchema] of Object.entries(formSchema.properties)) { - // TODO: Add support for filters - if (name === "filters") continue; - inputSchema.properties![name] = propSchema; + // Handle parameters (path, query, header, cookie) + if (operation.parameters) { + for (const param of operation.parameters) { + const paramObj = this.resolveParameter(param); + if (paramObj && paramObj.schema) { + // do not include Anytype-Version in the input schema, it's set in http client header by proxy + if (paramObj.name === "Anytype-Version") { + continue; + } + const schema = this.convertOpenApiSchemaToJsonSchema(paramObj.schema, new Set(), true); + // Merge parameter-level description if available + if (paramObj.description) { + schema.description = paramObj.description; } - if (formSchema.required) { - inputSchema.required!.push(...formSchema.required!.filter((r) => r !== "filters")); + inputSchema.properties![paramObj.name] = schema; + if (paramObj.required) { + inputSchema.required!.push(paramObj.name); } } } - // Handle application/json - else if (bodyObj.content["application/json"]?.schema) { - const bodySchema = this.convertOpenApiSchemaToJsonSchema( - bodyObj.content["application/json"].schema, - new Set(), - true, - ); - // Merge body schema into the inputSchema's properties - if (bodySchema.type === "object" && bodySchema.properties) { - for (const [name, propSchema] of Object.entries(bodySchema.properties)) { - // TODO: Add support for filters - if (name === "filters") continue; - inputSchema.properties![name] = propSchema; + } + + // Handle requestBody + if (operation.requestBody) { + const bodyObj = this.resolveRequestBody(operation.requestBody); + if (bodyObj?.content) { + // Handle multipart/form-data for file uploads + // We convert the multipart/form-data schema to a JSON schema and we require + // that the user passes in a string for each file that points to the local file + if (bodyObj.content["multipart/form-data"]?.schema) { + const formSchema = this.convertOpenApiSchemaToJsonSchema( + bodyObj.content["multipart/form-data"].schema, + new Set(), + true, + ); + if (formSchema.type === "object" && formSchema.properties) { + for (const [name, propSchema] of Object.entries(formSchema.properties)) { + // TODO: Add support for filters + if (name === "filters") continue; + inputSchema.properties![name] = propSchema; + } + if (formSchema.required) { + inputSchema.required!.push(...formSchema.required!.filter((r) => r !== "filters")); + } } - if (bodySchema.required) { - inputSchema.required!.push(...bodySchema.required!.filter((r) => r !== "filters")); + } + // Handle application/json + else if (bodyObj.content["application/json"]?.schema) { + const bodySchema = this.convertOpenApiSchemaToJsonSchema( + bodyObj.content["application/json"].schema, + new Set(), + true, + ); + // Merge body schema into the inputSchema's properties + if (bodySchema.type === "object" && bodySchema.properties) { + for (const [name, propSchema] of Object.entries(bodySchema.properties)) { + // TODO: Add support for filters + if (name === "filters") continue; + inputSchema.properties![name] = propSchema; + } + if (bodySchema.required) { + inputSchema.required!.push(...bodySchema.required!.filter((r) => r !== "filters")); + } + } else { + // If the request body is not an object, just put it under "body" + inputSchema.properties!["body"] = bodySchema; + inputSchema.required!.push("body"); } - } else { - // If the request body is not an object, just put it under "body" - inputSchema.properties!["body"] = bodySchema; - inputSchema.required!.push("body"); } } } diff --git a/src/tools/__tests__/object-tools.test.ts b/src/tools/__tests__/object-tools.test.ts new file mode 100644 index 0000000..b332d4a --- /dev/null +++ b/src/tools/__tests__/object-tools.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; +import { + CreateObjectSchema, + IconSchema, + PropertyValueSchema, + ToolOverrides, + UpdateObjectSchema, +} from "../object-tools"; + +describe("Object Tools Zod Schemas", () => { + describe("PropertyValueSchema Discriminated Union (All 11 Types)", () => { + it("should parse 'text' property", () => { + const input = { key: "notes", format: "text", text: "Hello Anytype" }; + const res = PropertyValueSchema.safeParse(input); + expect(res.success).toBe(true); + if (res.success) expect(res.data).toEqual(input); + }); + + it("should parse 'number' property including 0", () => { + const input = { key: "count", format: "number", number: 0 }; + const res = PropertyValueSchema.safeParse(input); + expect(res.success).toBe(true); + if (res.success) expect(res.data).toEqual(input); + }); + + it("should parse 'select' property and strip unknown properties", () => { + const input = { + key: "status", + format: "select", + select: "tag_active", + text: "", // extraneous + number: 0, // extraneous + }; + + const result = PropertyValueSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ + key: "status", + format: "select", + select: "tag_active", + }); + } + }); + + it("should parse 'multi_select' property", () => { + const input = { key: "tags", format: "multi_select", multi_select: ["tag1", "tag2"] }; + const res = PropertyValueSchema.safeParse(input); + expect(res.success).toBe(true); + if (res.success) expect(res.data).toEqual(input); + }); + + it("should parse 'date' property", () => { + const input = { key: "due_date", format: "date", date: "2026-08-21T12:00:00Z" }; + const res = PropertyValueSchema.safeParse(input); + expect(res.success).toBe(true); + if (res.success) expect(res.data).toEqual(input); + }); + + it("should parse 'files' property", () => { + const input = { key: "attachments", format: "files", files: ["bafy1", "bafy2"] }; + const res = PropertyValueSchema.safeParse(input); + expect(res.success).toBe(true); + if (res.success) expect(res.data).toEqual(input); + }); + + it("should parse 'checkbox' property including false", () => { + const input = { key: "done", format: "checkbox", checkbox: false }; + const res = PropertyValueSchema.safeParse(input); + expect(res.success).toBe(true); + if (res.success) expect(res.data).toEqual(input); + }); + + it("should parse 'url' property", () => { + const input = { key: "source", format: "url", url: "https://example.com" }; + const res = PropertyValueSchema.safeParse(input); + expect(res.success).toBe(true); + if (res.success) expect(res.data).toEqual(input); + }); + + it("should parse 'email' property", () => { + const input = { key: "contact", format: "email", email: "user@example.com" }; + const res = PropertyValueSchema.safeParse(input); + expect(res.success).toBe(true); + if (res.success) expect(res.data).toEqual(input); + }); + + it("should parse 'phone' property", () => { + const input = { key: "support", format: "phone", phone: "+1234567890" }; + const res = PropertyValueSchema.safeParse(input); + expect(res.success).toBe(true); + if (res.success) expect(res.data).toEqual(input); + }); + + it("should parse 'objects' property", () => { + const input = { key: "links", format: "objects", objects: ["bafyobj1", "bafyobj2"] }; + const res = PropertyValueSchema.safeParse(input); + expect(res.success).toBe(true); + if (res.success) expect(res.data).toEqual(input); + }); + + it("should reject invalid format or missing required payload", () => { + expect(PropertyValueSchema.safeParse({ key: "k", format: "unknown" }).success).toBe(false); + expect(PropertyValueSchema.safeParse({ key: "k", format: "select" }).success).toBe(false); + }); + }); + + describe("IconSchema", () => { + it("should validate emoji icon", () => { + const res = IconSchema.safeParse({ emoji: "🚀", format: "emoji" }); + expect(res.success).toBe(true); + }); + + it("should validate file icon", () => { + const res = IconSchema.safeParse({ file: "bafyimage123", format: "file" }); + expect(res.success).toBe(true); + }); + + it("should validate named icon", () => { + const res = IconSchema.safeParse({ name: "alarm", color: "blue", format: "icon" }); + expect(res.success).toBe(true); + }); + + it("should validate null to clear icon", () => { + const res = IconSchema.safeParse(null); + expect(res.success).toBe(true); + if (res.success) expect(res.data).toBeNull(); + }); + }); + + describe("CreateObjectSchema", () => { + it("should validate valid create-object payload with properties", () => { + const input = { + space_id: "space_123", + type_key: "page", + name: "My Note", + body: "Initial text", + icon: { emoji: "📝", format: "emoji" }, + properties: [ + { key: "status", format: "select", select: "tag_1" }, + { key: "priority", format: "number", number: 1 }, + ], + }; + + const result = CreateObjectSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it("should fail when space_id or type_key is missing", () => { + expect(CreateObjectSchema.safeParse({ type_key: "page", name: "No space" }).success).toBe(false); + expect(CreateObjectSchema.safeParse({ space_id: "space_123", name: "No type" }).success).toBe(false); + }); + }); + + describe("UpdateObjectSchema", () => { + it("should validate valid update-object payload", () => { + const input = { + space_id: "space_123", + object_id: "obj_456", + name: "Updated Note", + markdown: "# New content", + icon: null, + properties: [{ key: "done", format: "checkbox", checkbox: true }], + }; + + const result = UpdateObjectSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it("should fail when object_id is missing", () => { + expect(UpdateObjectSchema.safeParse({ space_id: "space_123", name: "Missing obj" }).success).toBe(false); + }); + }); + + describe("ToolOverrides Registry", () => { + it("should contain API-create-object and API-update-object overrides with clean JSON Schema", () => { + expect(ToolOverrides["API-create-object"]).toBeDefined(); + expect(ToolOverrides["API-update-object"]).toBeDefined(); + + const createSchema = ToolOverrides["API-create-object"].inputSchema; + expect(createSchema.type).toBe("object"); + expect(createSchema.properties).toHaveProperty("space_id"); + expect(createSchema.properties).toHaveProperty("type_key"); + expect(createSchema.properties).toHaveProperty("properties"); + // Check that root $schema is removed for OpenAI/MCP compatibility + expect(createSchema).not.toHaveProperty("$schema"); + + const updateSchema = ToolOverrides["API-update-object"].inputSchema; + expect(updateSchema.type).toBe("object"); + expect(updateSchema.properties).toHaveProperty("space_id"); + expect(updateSchema.properties).toHaveProperty("object_id"); + expect(updateSchema).not.toHaveProperty("$schema"); + }); + }); +}); diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..3345479 --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1 @@ +export * from "./object-tools"; diff --git a/src/tools/object-tools.ts b/src/tools/object-tools.ts new file mode 100644 index 0000000..897d385 --- /dev/null +++ b/src/tools/object-tools.ts @@ -0,0 +1,164 @@ +import type { JSONSchema7 as IJsonSchema } from "json-schema"; +import { z } from "zod"; + +// ============================================================================ +// 1. Zod Discriminated Union for Property Values +// ============================================================================ + +export const TextPropertySchema = z.object({ + key: z.string().describe("The property key (e.g. 'description', 'notes')"), + format: z.literal("text"), + text: z.string().describe("The text value of the property"), +}); + +export const NumberPropertySchema = z.object({ + key: z.string().describe("The property key (e.g. 'priority', 'count')"), + format: z.literal("number"), + number: z.number().describe("The numerical value of the property"), +}); + +export const SelectPropertySchema = z.object({ + key: z.string().describe("The property key (e.g. 'status')"), + format: z.literal("select"), + select: z.string().describe("Selected tag key or ID"), +}); + +export const MultiSelectPropertySchema = z.object({ + key: z.string().describe("The property key (e.g. 'tags')"), + format: z.literal("multi_select"), + multi_select: z.array(z.string()).describe("Array of selected tag keys or IDs"), +}); + +export const DatePropertySchema = z.object({ + key: z.string().describe("The property key (e.g. 'due_date')"), + format: z.literal("date"), + date: z.string().describe("Date in RFC3339 or ISO 8601 format (e.g. '2026-08-21T12:00:00Z' or '2026-08-21')"), +}); + +export const FilesPropertySchema = z.object({ + key: z.string().describe("The property key (e.g. 'attachments')"), + format: z.literal("files"), + files: z.array(z.string()).describe("Array of file IDs"), +}); + +export const CheckboxPropertySchema = z.object({ + key: z.string().describe("The property key (e.g. 'done', 'archived')"), + format: z.literal("checkbox"), + checkbox: z.boolean().describe("Boolean checkbox state (true/false)"), +}); + +export const UrlPropertySchema = z.object({ + key: z.string().describe("The property key (e.g. 'website', 'source')"), + format: z.literal("url"), + url: z.string().describe("URL string"), +}); + +export const EmailPropertySchema = z.object({ + key: z.string().describe("The property key (e.g. 'email', 'contact')"), + format: z.literal("email"), + email: z.string().describe("Email address"), +}); + +export const PhonePropertySchema = z.object({ + key: z.string().describe("The property key (e.g. 'phone')"), + format: z.literal("phone"), + phone: z.string().describe("Phone number string"), +}); + +export const ObjectsPropertySchema = z.object({ + key: z.string().describe("The property key (e.g. 'relations', 'links')"), + format: z.literal("objects"), + objects: z.array(z.string()).describe("Array of linked object IDs"), +}); + +export const PropertyValueSchema = z.discriminatedUnion("format", [ + TextPropertySchema, + NumberPropertySchema, + SelectPropertySchema, + MultiSelectPropertySchema, + DatePropertySchema, + FilesPropertySchema, + CheckboxPropertySchema, + UrlPropertySchema, + EmailPropertySchema, + PhonePropertySchema, + ObjectsPropertySchema, +]); + +// ============================================================================ +// 2. Handcrafted Zod Schemas for Icons and Objects +// ============================================================================ + +export const EmojiIconSchema = z.object({ + emoji: z.string().describe("Emoji character for the icon (e.g. '📄', '🚀')"), + format: z.literal("emoji").optional().describe("Icon format ('emoji')"), +}); + +export const FileIconSchema = z.object({ + file: z.string().describe("File ID of the icon image"), + format: z.literal("file").describe("Icon format ('file')"), +}); + +export const NamedIconSchema = z.object({ + name: z.string().describe("Name of the icon (e.g. 'alarm', 'archive', 'star')"), + color: z.string().optional().describe("Color of the icon"), + format: z.literal("icon").optional().describe("Icon format ('icon')"), +}); + +export const IconSchema = z + .union([EmojiIconSchema, FileIconSchema, NamedIconSchema]) + .nullable() + .describe("The icon of the object, or null to remove the icon"); + +export const CreateObjectSchema = z + .object({ + space_id: z.string().describe("The ID of the space to create the object in"), + type_key: z.string().describe("The key of the object type (e.g. 'page', 'task', 'bookmark')"), + name: z.string().optional().describe("The name of the object"), + body: z.string().optional().describe("The initial body text or markdown of the object"), + icon: IconSchema.optional(), + template_id: z.string().optional().describe("The ID of the template to instantiate from"), + properties: z.array(PropertyValueSchema).optional().describe("Object properties to set"), + }) + .passthrough(); + +export const UpdateObjectSchema = z + .object({ + space_id: z.string().describe("The ID of the space containing the object"), + object_id: z.string().describe("The ID of the object to update"), + name: z.string().optional().describe("The updated name of the object"), + markdown: z.string().optional().describe("The updated markdown body of the object"), + icon: IconSchema.optional(), + type_key: z.string().optional().describe("The key of the type of object"), + properties: z.array(PropertyValueSchema).optional().describe("Object properties to set/update"), + }) + .passthrough(); + +export interface ToolOverride { + zodSchema: z.ZodType; + inputSchema: IJsonSchema & { type: "object" }; +} + +/** + * Generate a clean JSON Schema without root $schema header for MCP / LLM compatibility. + */ +function cleanJsonSchema(schema: z.ZodType): IJsonSchema & { type: "object" } { + const jsonSchema = z.toJSONSchema(schema) as Record; + delete jsonSchema.$schema; + return jsonSchema as IJsonSchema & { type: "object" }; +} + +// ============================================================================ +// 3. Tool Overrides Registry +// ============================================================================ + +export const ToolOverrides: Record = { + "API-create-object": { + zodSchema: CreateObjectSchema, + inputSchema: cleanJsonSchema(CreateObjectSchema), + }, + "API-update-object": { + zodSchema: UpdateObjectSchema, + inputSchema: cleanJsonSchema(UpdateObjectSchema), + }, +};