diff --git a/.gitignore b/.gitignore index b101def..173236e 100644 --- a/.gitignore +++ b/.gitignore @@ -159,6 +159,10 @@ bin .vscode-test +# VSCode local settings + +.vscode + # yarn v2 .yarn/cache diff --git a/README.md b/README.md index bd65934..15546c6 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,17 @@ npm install -g @anyproto/anytype-mcp +## Environment Variables + +| Variable | Default | Description | +| ------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OPENAPI_MCP_HEADERS` | — | JSON object of headers forwarded to the Anytype API on every request. Required for auth: `{"Authorization":"Bearer ", "Anytype-Version":"2025-11-08"}` | +| `ANYTYPE_API_BASE_URL` | `http://127.0.0.1:31009` | Anytype API base URL. Set to `http://localhost:31012` for `anytype-cli`. | +| `MCP_TRANSPORT` | `stdio` | Transport mode. Set to `http` to enable the Streamable HTTP server. | +| `MCP_HOST` | `127.0.0.1` | Host to bind when `MCP_TRANSPORT=http`. | +| `MCP_PORT` | `3666` | Port to listen on when `MCP_TRANSPORT=http`. Must be in range 1024–65535. | +| `MCP_PASSTHROUGH_HEADERS` | `authorization,anytype-version` | Comma-separated list of inbound HTTP header names (lowercase) forwarded from the MCP HTTP client to the Anytype API. Extend with caution — arbitrary headers must not be forwarded. | + ### Custom API Base URL By default, the server connects to `http://127.0.0.1:31009`. For `anytype-cli` (port `31012`) or other custom base URLs, set `ANYTYPE_API_BASE_URL`: @@ -102,6 +113,7 @@ By default, the server connects to `http://127.0.0.1:31009`. For `anytype-cli` ( Example Configuration **MCP Client (Claude Desktop, Cursor, etc.):** + ```json { "mcpServers": { @@ -118,6 +130,7 @@ By default, the server connects to `http://127.0.0.1:31009`. For `anytype-cli` ( ``` **Claude Code (CLI):** + ```bash claude mcp add anytype \ -e ANYTYPE_API_BASE_URL='http://localhost:31012' \ @@ -165,6 +178,18 @@ npm run build npm link ``` +### Running in HTTP Transport Mode + +Useful for browser-based clients such as [MCP Inspector](https://github.com/modelcontextprotocol/inspector): + +```bash +MCP_TRANSPORT=http MCP_HOST=127.0.0.1 MCP_PORT=3666 npm run dev +``` + +Then connect your MCP client to `http://127.0.0.1:3666/mcp`. + +Auth is passed through from the MCP client — set `Authorization: Bearer ` and `Anytype-Version: 2025-11-08` in your client's request headers. Alternatively, set `OPENAPI_MCP_HEADERS` as with stdio mode. + ## Contribution Thank you for your desire to develop Anytype together! @@ -181,4 +206,4 @@ Thank you for your desire to develop Anytype together! Made by Any — a Swiss association 🇨🇭 -Licensed under [MIT](./LICENSE.md). +Licensed under [MIT](./LICENSE.md). \ No newline at end of file diff --git a/cli/openapi-client.ts b/cli/openapi-client.ts index 9e7ccb1..d484a47 100644 --- a/cli/openapi-client.ts +++ b/cli/openapi-client.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import axios from "axios"; -import fs from "fs/promises"; +import * as fs from "fs/promises"; import { OpenAPIV3 } from "openapi-types"; import { HttpClient, OpenAPIToMCPConverter } from "../src"; diff --git a/scripts/__tests__/start-server.test.ts b/scripts/__tests__/start-server.test.ts index 27ee7cc..010f186 100644 --- a/scripts/__tests__/start-server.test.ts +++ b/scripts/__tests__/start-server.test.ts @@ -3,6 +3,10 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { loadOpenApiSpec, ValidationError } from "../../src/init-server"; +import { overrideSpecPath } from "../../src/utils/base-url"; + +// Reset specPathOverride after each test to avoid inter-test contamination +afterEach(() => overrideSpecPath(undefined)); // Mock fs and axios vi.mock("node:fs"); @@ -72,8 +76,9 @@ describe("loadOpenApiSpec", () => { it("should load a valid OpenAPI spec from local file", async () => { // Mock fs.readFileSync to return a valid spec vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(validOpenApiSpec)); + overrideSpecPath("./test-spec.json"); - const result = await loadOpenApiSpec("./test-spec.json"); + const result = await loadOpenApiSpec(); expect(result).toEqual(validOpenApiSpec); expect(fs.readFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), "./test-spec.json"), "utf-8"); @@ -84,11 +89,12 @@ describe("loadOpenApiSpec", () => { vi.mocked(fs.readFileSync).mockImplementation(() => { throw new Error("ENOENT: no such file or directory"); }); + overrideSpecPath("./non-existent.json"); // Mock process.exit to prevent actual exit const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as any); - await loadOpenApiSpec("./non-existent.json"); + await loadOpenApiSpec(); expect(console.error).toHaveBeenCalledWith("Failed to read OpenAPI specification file:", expect.any(String)); expect(mockExit).toHaveBeenCalledWith(1); @@ -97,11 +103,12 @@ describe("loadOpenApiSpec", () => { it("should handle invalid JSON", async () => { // Mock fs.readFileSync to return invalid JSON vi.mocked(fs.readFileSync).mockReturnValue("invalid json"); + overrideSpecPath("./invalid.json"); // Mock process.exit const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as any); - await loadOpenApiSpec("./invalid.json"); + await loadOpenApiSpec(); expect(console.error).toHaveBeenCalledWith("Failed to parse OpenAPI specification:", expect.any(String)); expect(mockExit).toHaveBeenCalledWith(1); @@ -111,8 +118,9 @@ describe("loadOpenApiSpec", () => { // Mock fs.readFileSync to return a valid YAML spec const yamlSpec = JSON.stringify(validOpenApiSpec); vi.mocked(fs.readFileSync).mockReturnValue(yamlSpec); + overrideSpecPath("./test-spec.yaml"); - const result = await loadOpenApiSpec("./test-spec.yaml"); + const result = await loadOpenApiSpec(); expect(result).toEqual(validOpenApiSpec); expect(fs.readFileSync).toHaveBeenCalledWith(path.resolve(process.cwd(), "./test-spec.yaml"), "utf-8"); @@ -121,11 +129,12 @@ describe("loadOpenApiSpec", () => { it("should handle invalid YAML", async () => { // Mock fs.readFileSync to return invalid YAML vi.mocked(fs.readFileSync).mockReturnValue("invalid: yaml: :"); + overrideSpecPath("./invalid.yaml"); // Mock process.exit const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as any); - await loadOpenApiSpec("./invalid.yaml"); + await loadOpenApiSpec(); expect(console.error).toHaveBeenCalledWith("Failed to parse OpenAPI specification:", expect.any(String)); expect(mockExit).toHaveBeenCalledWith(1); @@ -136,8 +145,9 @@ describe("loadOpenApiSpec", () => { it("should load a valid OpenAPI spec from URL", async () => { // Mock axios.get to return a valid spec vi.mocked(axios.get).mockResolvedValue({ data: validOpenApiSpec }); + overrideSpecPath("http://example.com/api-spec.json"); - const result = await loadOpenApiSpec("http://example.com/api-spec.json"); + const result = await loadOpenApiSpec(); expect(result).toEqual(validOpenApiSpec); expect(axios.get).toHaveBeenCalledWith("http://example.com/api-spec.json"); @@ -146,11 +156,12 @@ describe("loadOpenApiSpec", () => { it("should handle network errors", async () => { // Mock axios.get to throw network error vi.mocked(axios.get).mockRejectedValue(new Error("Network Error")); + overrideSpecPath("http://example.com/api-spec.json"); // Mock process.exit const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as any); - await loadOpenApiSpec("http://example.com/api-spec.json"); + await loadOpenApiSpec(); expect(console.error).toHaveBeenCalledWith("Failed to fetch OpenAPI specification from URL:", "Network Error"); expect(mockExit).toHaveBeenCalledWith(1); @@ -159,11 +170,12 @@ describe("loadOpenApiSpec", () => { it("should handle invalid response data", async () => { // Mock axios.get to return invalid data vi.mocked(axios.get).mockResolvedValue({ data: "invalid data" }); + overrideSpecPath("http://example.com/api-spec.json"); // Mock process.exit const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as any); - await loadOpenApiSpec("http://example.com/api-spec.json"); + await loadOpenApiSpec(); expect(console.error).toHaveBeenCalledWith("Failed to parse OpenAPI specification:", expect.any(String)); expect(mockExit).toHaveBeenCalledWith(1); @@ -173,8 +185,9 @@ describe("loadOpenApiSpec", () => { // Mock axios.get to return a valid YAML spec const yamlSpec = JSON.stringify(validOpenApiSpec); vi.mocked(axios.get).mockResolvedValue({ data: yamlSpec }); + overrideSpecPath("http://example.com/api-spec.yaml"); - const result = await loadOpenApiSpec("http://example.com/api-spec.yaml"); + const result = await loadOpenApiSpec(); expect(result).toEqual(validOpenApiSpec); expect(axios.get).toHaveBeenCalledWith("http://example.com/api-spec.yaml"); diff --git a/scripts/start-server.ts b/scripts/start-server.ts index 13d5aef..f4bbb85 100644 --- a/scripts/start-server.ts +++ b/scripts/start-server.ts @@ -1,9 +1,9 @@ import { ApiKeyGenerator } from "../src/auth/get-key"; import { initProxy, loadOpenApiSpec, ValidationError } from "../src/init-server"; -import { determineBaseUrl } from "../src/utils/base-url"; +import { determineBaseUrl, overrideSpecPath } from "../src/utils/base-url"; -async function generateApiKey(specPath?: string) { - const openApiSpec = await loadOpenApiSpec(specPath); +async function generateApiKey() { + const openApiSpec = await loadOpenApiSpec(); const baseUrl = determineBaseUrl(openApiSpec); const generator = new ApiKeyGenerator(baseUrl); await generator.generateApiKey(); @@ -11,10 +11,11 @@ async function generateApiKey(specPath?: string) { export async function main(args: string[] = process.argv.slice(2)) { const [command, specPath] = args; + overrideSpecPath(specPath); if (!command || command === "run") { - await initProxy(specPath); + await initProxy(); } else if (command === "get-key") { - await generateApiKey(specPath); + await generateApiKey(); } else { console.error(`Error: Unknown command "${command}"`); process.exit(1); diff --git a/src/client/__tests__/http-client.test.ts b/src/client/__tests__/http-client.test.ts index 1d52403..6a5be6e 100644 --- a/src/client/__tests__/http-client.test.ts +++ b/src/client/__tests__/http-client.test.ts @@ -1,9 +1,17 @@ +import axios from "axios"; import { Headers } from "node-fetch"; import OpenAPIClientAxios from "openapi-client-axios"; import { OpenAPIV3 } from "openapi-types"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { HttpClient } from "../http-client"; +function makeAxiosError(status: number, statusText: string, data: any, headers: Record = {}) { + const err = new axios.AxiosError(statusText); + err.response = { status, statusText, data, headers, config: err.config! } as any; + err.isAxiosError = true; + return err; +} + // Mock the OpenAPIClientAxios initialization const mockApi = { getPet: vi.fn(), @@ -65,7 +73,7 @@ describe("HttpClient", () => { beforeEach(async () => { // Create a new instance of HttpClient - client = new HttpClient({ baseUrl: "https://api.example.com" }, sampleSpec); + client = new HttpClient({ baseUrl: "https://api.example.com", headers: {} }, sampleSpec); // Await the initialization to ensure mockApi is set correctly mockApi = await client["api"]; }); @@ -125,20 +133,16 @@ describe("HttpClient", () => { }); it("handles API errors correctly", async () => { - const error = { - response: { - status: 404, - statusText: "Not Found", - data: { - code: "RESOURCE_NOT_FOUND", - message: "Pet not found", - petId: 999, - }, - headers: { - "content-type": "application/json", - }, + const error = makeAxiosError( + 404, + "Not Found", + { + code: "RESOURCE_NOT_FOUND", + message: "Pet not found", + petId: 999, }, - }; + { "content-type": "application/json" }, + ); mockApi.getPet.mockRejectedValueOnce(error); await expect(client.executeOperation(getPetOperation, { petId: 999 })).rejects.toMatchObject({ @@ -153,29 +157,19 @@ describe("HttpClient", () => { }); it("handles validation errors (400) correctly", async () => { - const error = { - response: { - status: 400, - statusText: "Bad Request", - data: { - code: "VALIDATION_ERROR", - message: "Invalid input data", - errors: [ - { - field: "age", - message: "Age must be a positive number", - }, - { - field: "name", - message: "Name is required", - }, - ], - }, - headers: { - "content-type": "application/json", - }, + const error = makeAxiosError( + 400, + "Bad Request", + { + code: "VALIDATION_ERROR", + message: "Invalid input data", + errors: [ + { field: "age", message: "Age must be a positive number" }, + { field: "name", message: "Name is required" }, + ], }, - }; + { "content-type": "application/json" }, + ); mockApi.getPet.mockRejectedValueOnce(error); await expect(client.executeOperation(getPetOperation, { petId: 1 })).rejects.toMatchObject({ @@ -199,16 +193,12 @@ describe("HttpClient", () => { }); it("handles server errors (500) with HTML response", async () => { - const error = { - response: { - status: 500, - statusText: "Internal Server Error", - data: "

500 Internal Server Error

", - headers: { - "content-type": "text/html", - }, - }, - }; + const error = makeAxiosError( + 500, + "Internal Server Error", + "

500 Internal Server Error

", + { "content-type": "text/html" }, + ); mockApi.getPet.mockRejectedValueOnce(error); await expect(client.executeOperation(getPetOperation, { petId: 1 })).rejects.toMatchObject({ @@ -219,21 +209,16 @@ describe("HttpClient", () => { }); it("handles rate limit errors (429)", async () => { - const error = { - response: { - status: 429, - statusText: "Too Many Requests", - data: { - code: "RATE_LIMIT_EXCEEDED", - message: "Rate limit exceeded", - retryAfter: 60, - }, - headers: { - "content-type": "application/json", - "retry-after": "60", - }, + const error = makeAxiosError( + 429, + "Too Many Requests", + { + code: "RATE_LIMIT_EXCEEDED", + message: "Rate limit exceeded", + retryAfter: 60, }, - }; + { "content-type": "application/json", "retry-after": "60" }, + ); mockApi.getPet.mockRejectedValueOnce(error); await expect(client.executeOperation(getPetOperation, { petId: 1 })).rejects.toMatchObject({ @@ -296,7 +281,7 @@ describe("HttpClient", () => { throw new Error("Test setup error: post operation not found"); } - const client = new HttpClient({ baseUrl: "http://test.com" }, testSpec); + const client = new HttpClient({ baseUrl: "http://test.com", headers: {} }, testSpec); await client.executeOperation(postOperation, { foo: "bar" }); @@ -378,7 +363,7 @@ describe("HttpClient", () => { throw new Error("Test setup error: complex operation not found"); } - const client = new HttpClient({ baseUrl: "http://test.com" }, complexSpec); + const client = new HttpClient({ baseUrl: "http://test.com", headers: {} }, complexSpec); await client.executeOperation(complexOperation, { // Path parameter @@ -449,6 +434,7 @@ describe("HttpClient", () => { const mockConfig = { baseUrl: "http://test-api.com", + headers: {}, }; beforeEach(() => { @@ -456,20 +442,16 @@ describe("HttpClient", () => { }); it("should properly propagate structured error responses", async () => { - const errorResponse = { - response: { - data: { - code: "VALIDATION_ERROR", - message: "Invalid input", - details: ["Field x is required"], - }, - status: 400, - statusText: "Bad Request", - headers: { - "content-type": "application/json", - }, + const errorResponse = makeAxiosError( + 400, + "Bad Request", + { + code: "VALIDATION_ERROR", + message: "Invalid input", + details: ["Field x is required"], }, - }; + { "content-type": "application/json" }, + ); // Mock axios instance const mockAxiosInstance = { diff --git a/src/client/http-client.ts b/src/client/http-client.ts index 2ca7b7c..2c44cdb 100644 --- a/src/client/http-client.ts +++ b/src/client/http-client.ts @@ -1,15 +1,12 @@ -import type { AxiosInstance } from "axios"; +import axios, { type AxiosInstance } from "axios"; import FormData from "form-data"; import fs from "fs"; import { Headers } from "node-fetch"; import OpenAPIClientAxios from "openapi-client-axios"; import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; import { isFileUploadParameter } from "../openapi/file-upload"; - -export type HttpClientConfig = { - baseUrl: string; - headers?: Record; -}; +import { DEFAULT_BASE_URL } from "../utils/base-url"; +import type { HttpClientConfig } from "../utils/config"; export type HttpClientResponse = { data: T; @@ -32,28 +29,42 @@ export class HttpClientError extends Error { export class HttpClient { private api: Promise; private client: OpenAPIClientAxios; + private baseHeaders: Record; constructor(config: HttpClientConfig, openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document) { + this.baseHeaders = { ...config.headers }; + const baseURL = config.baseUrl ?? (openApiSpec as OpenAPIV3.Document)?.servers?.[0]?.url ?? DEFAULT_BASE_URL; // @ts-expect-error OpenAPIClientAxios can be imported as default or named export, we handle both cases this.client = new (OpenAPIClientAxios.default ?? OpenAPIClientAxios)({ definition: openApiSpec, axiosConfigDefaults: { - baseURL: config.baseUrl, + baseURL, headers: { "Content-Type": "application/json", "User-Agent": "anytype-mcp-server", - ...config.headers, + ...this.baseHeaders, }, }, }); this.api = this.client.init(); } + /** + * Returns a new HttpClient that merges the given headers into every request. + * Per-request headers (e.g. Authorization passthrough) take precedence over base headers. + */ + withHeaders(headers: Record): HttpClient { + const clone = Object.create(HttpClient.prototype) as HttpClient; + clone.baseHeaders = { ...this.baseHeaders, ...headers }; + clone.client = this.client; + clone.api = this.api; + return clone; + } + private async prepareFileUpload( operation: OpenAPIV3.OperationObject, params: Record, ): Promise { - console.error("prepareFileUpload", { operation, params }); const fileParams = isFileUploadParameter(operation); if (fileParams.length === 0) return null; @@ -61,7 +72,6 @@ export class HttpClient { // Handle file uploads for (const param of fileParams) { - console.error(`extracting ${param}`, { params }); const filePath = params[param]; if (!filePath) { throw new Error(`File path must be provided for parameter: ${param}`); @@ -163,15 +173,13 @@ export class HttpClient { : { ...(hasBody ? { "Content-Type": "application/json" } : { "Content-Type": null }) }; const requestConfig = { headers: { + ...this.baseHeaders, ...headers, }, }; // first argument is url parameters, second is body parameters - console.error("calling operation", { operationId, urlParameters, bodyParams, requestConfig }); const response = await operationFn(urlParameters, hasBody ? bodyParams : undefined, requestConfig); - - console.error("operation finished"); // Convert axios headers to Headers object const responseHeaders = new Headers(); Object.entries(response.headers).forEach(([key, value]) => { @@ -184,13 +192,20 @@ export class HttpClient { headers: responseHeaders, }; } catch (error: any) { - if (error.response) { - console.error("Error in http client", error); + if (axios.isAxiosError(error) && error.response) { const headers = new Headers(); Object.entries(error.response.headers).forEach(([key, value]) => { if (value) headers.append(key, value.toString()); }); - + console.error("HTTP error", { + operationId, + status: error.response.status, + statusText: error.response.statusText, + data: error.response.data, + requestUrl: error.config?.url, + requestMethod: error.config?.method, + requestData: error.config?.data, + }); throw new HttpClientError( error.response.statusText || "Request failed", error.response.status, diff --git a/src/init-server.ts b/src/init-server.ts index 92794be..8339eef 100644 --- a/src/init-server.ts +++ b/src/init-server.ts @@ -3,8 +3,10 @@ import axios from "axios"; import fs from "node:fs"; import path from "node:path"; import { OpenAPIV3 } from "openapi-types"; +import { startHttpTransport } from "./mcp/http-transport"; import { MCPProxy } from "./mcp/proxy"; -import { getDefaultSpecUrl } from "./utils/base-url"; +import { resolveSpecPath } from "./utils/base-url"; +import { getConfig } from "./utils/config"; export class ValidationError extends Error { constructor(public errors: any[]) { @@ -13,9 +15,9 @@ export class ValidationError extends Error { } } -export async function loadOpenApiSpec(specPath?: string): Promise { - const finalSpec = specPath || getDefaultSpecUrl(); - let rawSpec: string; +export async function loadOpenApiSpec(): Promise { + const finalSpec = resolveSpecPath(); + let rawSpec: string | undefined; if (finalSpec.startsWith("http://") || finalSpec.startsWith("https://")) { try { @@ -47,11 +49,16 @@ export async function loadOpenApiSpec(specPath?: string): Promise { }); }); - describe("parseHeadersFromEnv", () => { - const originalEnv = process.env; - const expectHeaders = (headers: Record) => { - expect(HttpClient).toHaveBeenCalledWith(expect.objectContaining({ headers }), expect.anything()); - }; - - beforeEach(() => { - process.env = { ...originalEnv }; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - it("should parse valid JSON headers from env", () => { - process.env.OPENAPI_MCP_HEADERS = JSON.stringify({ - Authorization: "Bearer token123", - "X-Custom-Header": "test", - }); + describe("openApiHeaders", () => { + it("should pass httpClient config from getConfig() to HttpClient", () => { + // Config parsing is tested in proxy-config.test.ts. + // Here we only verify that MCPProxy forwards getConfig().httpClient as-is. new MCPProxy("test-proxy", mockOpenApiSpec); - expectHeaders({ Authorization: "Bearer token123", "X-Custom-Header": "test" }); - }); - - it("should return empty object when env var is not set", () => { - delete process.env.OPENAPI_MCP_HEADERS; - new MCPProxy("test-proxy", mockOpenApiSpec); - expectHeaders({}); - }); - - it("should return empty object and warn on invalid JSON", () => { - const consoleSpy = vi.spyOn(console, "warn"); - process.env.OPENAPI_MCP_HEADERS = "invalid json"; - new MCPProxy("test-proxy", mockOpenApiSpec); - expectHeaders({}); - expect(consoleSpy).toHaveBeenCalledWith( - "Failed to parse OPENAPI_MCP_HEADERS environment variable:", - expect.any(Error), - ); - }); - - it("should return empty object and warn on non-object JSON", () => { - const consoleSpy = vi.spyOn(console, "warn"); - process.env.OPENAPI_MCP_HEADERS = '"string"'; - new MCPProxy("test-proxy", mockOpenApiSpec); - expectHeaders({}); - expect(consoleSpy).toHaveBeenCalledWith( - "OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:", - "string", + expect(HttpClient).toHaveBeenCalledWith( + expect.objectContaining({ headers: expect.any(Object) }), + expect.anything(), ); }); }); describe("base URL integration", () => { - const originalEnv = process.env; - const expectBaseUrl = (url: string) => { - expect(HttpClient).toHaveBeenCalledWith(expect.objectContaining({ baseUrl: url }), expect.anything()); - }; - - beforeEach(() => { - process.env = { ...originalEnv }; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - it("should use ANYTYPE_API_BASE_URL when set", () => { - process.env.ANYTYPE_API_BASE_URL = "http://localhost:31012"; - new MCPProxy("test-proxy", mockOpenApiSpec); - expectBaseUrl("http://localhost:31012"); - }); - - it("should use spec servers when env var not set", () => { - delete process.env.ANYTYPE_API_BASE_URL; + // Base URL resolution priority is tested in base-url.test.ts. + // Here we verify MCPProxy passes getConfig().httpClient to HttpClient (baseUrl may be undefined). + it("should pass httpClient config to HttpClient", () => { new MCPProxy("test-proxy", mockOpenApiSpec); - expectBaseUrl("http://localhost:3000"); - }); - - it("should use default when neither env var nor spec servers available", () => { - delete process.env.ANYTYPE_API_BASE_URL; - new MCPProxy("test-proxy", createMockOpenApiSpec({ servers: undefined })); - expectBaseUrl("http://127.0.0.1:31009"); + expect(HttpClient).toHaveBeenCalledWith( + expect.objectContaining({ headers: expect.any(Object) }), + expect.anything(), + ); }); }); diff --git a/src/mcp/http-transport.ts b/src/mcp/http-transport.ts new file mode 100644 index 0000000..39d070d --- /dev/null +++ b/src/mcp/http-transport.ts @@ -0,0 +1,69 @@ +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import http from "node:http"; +import { MCPProxy } from "./proxy"; + +export const CORS_HEADERS = { + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Protocol-Version", + "Access-Control-Max-Age": "86400", +}; + +const MCP_HTTP_PATH = "/mcp"; + +export function applyCorsHeaders(req: http.IncomingMessage, res: http.ServerResponse): void { + const origin = req.headers.origin; + if (!origin) return; + res.setHeader("Access-Control-Allow-Origin", origin); + Object.entries(CORS_HEADERS).forEach(([k, v]) => res.setHeader(k, v)); +} + +export async function startHttpTransport( + proxy: MCPProxy, + host: string, + port: number, + passthroughHeaders: string[], +): Promise { + const server = http.createServer(async (req, res) => { + const { method, url } = req; + console.error(`[http] ${method} ${url}`); + + applyCorsHeaders(req, res); + + if (url !== MCP_HTTP_PATH) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + return; + } + + switch (method) { + case "OPTIONS": + res.writeHead(204); + res.end(); + break; + + case "GET": + case "POST": { + // Forward only whitelisted headers from MCP client to upstream Anytype API + const requestHeaders: Record = {}; + for (const name of passthroughHeaders) { + const value = req.headers[name]; + if (typeof value === "string") requestHeaders[name] = value; + } + + // Stateless mode: fresh transport per request + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await proxy.clone(requestHeaders).connect(transport); + res.on("close", () => transport.close().catch(() => {})); + await transport.handleRequest(req, res); + break; + } + + default: + res.writeHead(405, { Allow: CORS_HEADERS["Access-Control-Allow-Methods"], "Content-Type": "text/plain" }); + res.end("Method Not Allowed: MCP endpoint accepts POST only"); + } + }); + + await new Promise((resolve) => server.listen(port, host, resolve)); + console.error(`HTTP transport on http://${host}:${port}${MCP_HTTP_PATH}`); +} diff --git a/src/mcp/proxy.ts b/src/mcp/proxy.ts index 537ae22..ef5f242 100644 --- a/src/mcp/proxy.ts +++ b/src/mcp/proxy.ts @@ -1,12 +1,12 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from "@modelcontextprotocol/sdk/types.js"; -import { JSONSchema7 as IJsonSchema } from "json-schema"; import { Headers } from "node-fetch"; import { OpenAPIV3 } from "openapi-types"; +import pkg from "../../package.json"; import { HttpClient, HttpClientError } from "../client/http-client"; -import { OpenAPIToMCPConverter } from "../openapi/parser"; -import { determineBaseUrl } from "../utils/base-url"; +import { OpenAPIToMCPConverter, type ToolMethod } from "../openapi/parser"; +import { getConfig } from "../utils/config"; type PathItemObject = OpenAPIV3.PathItemObject & { get?: OpenAPIV3.OperationObject; @@ -16,55 +16,68 @@ type PathItemObject = OpenAPIV3.PathItemObject & { patch?: OpenAPIV3.OperationObject; }; -type NewToolDefinition = { - methods: Array<{ - name: string; - description: string; - inputSchema: IJsonSchema & { type: "object" }; - outputSchema?: IJsonSchema; - }>; -}; - export class MCPProxy { private server: Server; private httpClient: HttpClient; - private tools: Record; + private methods: Record; private openApiLookup: Record; + private state: { + toolsLogged: boolean; + serverInfo: ConstructorParameters[0]; + serverOptions: NonNullable[1]>; + }; constructor(name: string, openApiSpec: OpenAPIV3.Document) { - this.server = new Server({ name, version: "1.0.0" }, { capabilities: { tools: {} } }); - const baseUrl = determineBaseUrl(openApiSpec); - this.httpClient = new HttpClient( - { - baseUrl, - headers: this.parseHeadersFromEnv(), - }, - openApiSpec, - ); + this.state = { + toolsLogged: false, + serverInfo: { name, version: pkg.version, description: `Anytype API proxy (spec v${openApiSpec.info.version})` }, + serverOptions: { capabilities: { tools: {} } }, + }; + this.server = new Server(this.state.serverInfo, this.state.serverOptions); + this.httpClient = new HttpClient(getConfig().httpClient, openApiSpec); // Convert OpenAPI spec to MCP tools - const converter = new OpenAPIToMCPConverter(openApiSpec); - const { tools, openApiLookup } = converter.convertToMCPTools(); - this.tools = tools; + const converter = new OpenAPIToMCPConverter(openApiSpec, { + skipToolNamePrefix: true, + stripErrResponseDescriptions: true, + }); + const { methods, openApiLookup } = converter.convertToMCPTools(); + this.methods = methods; this.openApiLookup = openApiLookup; this.setupHandlers(); } - private setupHandlers() { + setupHandlers() { // Handle tool listing this.server.setRequestHandler(ListToolsRequestSchema, async () => { - const tools: Tool[] = []; + const toolsByMethodName = new Map(); // Add methods as separate tools to match the MCP format - Object.entries(this.tools).forEach(([toolName, def]) => { - def.methods.forEach((method) => { - const toolNameWithMethod = `${toolName}-${method.name}`; + Object.entries(this.methods).forEach(([toolName, methods]) => { + methods.forEach((method) => { + const methodName = method.name; + let bucket = toolsByMethodName.get(methodName); + if (!bucket) { + bucket = []; + toolsByMethodName.set(methodName, bucket); + } + bucket.push({ toolName, method }); + }); + }); + + const tools: Tool[] = []; + + toolsByMethodName.forEach((bucket) => { + const isCollision = bucket.length > 1; + bucket.forEach(({ toolName, method }) => { + const toolNameWithMethod = isCollision ? `${toolName}-${method.name}` : method.name; const truncatedToolName = this.truncateToolName(toolNameWithMethod); tools.push({ name: truncatedToolName, description: method.description, inputSchema: method.inputSchema as Tool["inputSchema"], + annotations: method.annotations, }); }); }); @@ -74,12 +87,17 @@ export class MCPProxy { // Handle tool calling this.server.setRequestHandler(CallToolRequestSchema, async (request) => { - console.error("calling tool", request.params); const { name, arguments: params } = request.params; // Find the operation in OpenAPI spec const operation = this.findOperation(name); - console.error("operations", this.openApiLookup); + + if (!this.state.toolsLogged) { + const toolNames = Object.keys(this.openApiLookup); + console.error(`tools (count: ${toolNames.length}): ${toolNames.join(", ")}`); + this.state.toolsLogged = true; + } + if (!operation) { throw new Error(`Method ${name} not found`); } @@ -98,23 +116,25 @@ export class MCPProxy { ], }; } catch (error) { - console.error("Error in tool call", error); if (error instanceof HttpClientError) { - console.error("HttpClientError encountered, returning structured error", error); const data = error.data?.response?.data ?? error.data ?? {}; return { + isError: true, content: [ { type: "text", - text: JSON.stringify({ - status: "error", // TODO: get this from http status code? - ...(typeof data === "object" ? data : { data: data }), - }), + text: JSON.stringify( + typeof data === "object" ? { httpStatus: error.status, ...data } : { httpStatus: error.status, data }, + ), }, ], }; } - throw error; + + console.error(`Unexpected error in "${name}" tool call`, error); + + // don’t leak internals or secrets, throw opaque error + throw new Error("Internal server error while handling MCP request"); } }); } @@ -123,25 +143,6 @@ export class MCPProxy { return this.openApiLookup[operationId] ?? null; } - private parseHeadersFromEnv(): Record { - const headersJson = process.env.OPENAPI_MCP_HEADERS; - if (!headersJson) { - return {}; - } - - try { - const headers = JSON.parse(headersJson); - if (typeof headers !== "object" || headers === null) { - console.warn("OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:", typeof headers); - return {}; - } - return headers; - } catch (error) { - console.warn("Failed to parse OPENAPI_MCP_HEADERS environment variable:", error); - return {}; - } - } - private getContentType(headers: Headers): "text" | "image" | "binary" { const contentType = headers.get("content-type"); if (!contentType) return "binary"; @@ -165,4 +166,21 @@ export class MCPProxy { // The SDK will handle stdio communication await this.server.connect(transport); } + + /** + * Creates a lightweight clone that reuses pre-parsed tools and HTTP client + * but has a fresh Server instance, required for stateless HTTP transport + * where each request needs its own Server/transport pair. + * Optionally merges per-request headers (e.g. Authorization passthrough). + */ + clone(requestHeaders?: Record): MCPProxy { + const instance = Object.create(MCPProxy.prototype) as MCPProxy; + instance.state = this.state; // shared reference — mutations visible across clones + instance.server = new Server(this.state.serverInfo, this.state.serverOptions); + instance.httpClient = requestHeaders ? this.httpClient.withHeaders(requestHeaders) : this.httpClient; + instance.tools = this.tools; + instance.openApiLookup = this.openApiLookup; + instance.setupHandlers(); + return instance; + } } diff --git a/src/openapi/__tests__/parser-multipart.test.ts b/src/openapi/__tests__/parser-multipart.test.ts index 4267e8e..54bd3f5 100644 --- a/src/openapi/__tests__/parser-multipart.test.ts +++ b/src/openapi/__tests__/parser-multipart.test.ts @@ -53,12 +53,14 @@ describe("OpenAPI Multipart Form Parser", () => { }; const converter = new OpenAPIToMCPConverter(spec); - const { tools } = converter.convertToMCPTools(); - expect(Object.keys(tools)).toHaveLength(1); + const { methods } = converter.convertToMCPTools(); - const [tool] = Object.values(tools); - expect(tool.methods).toHaveLength(1); - const [method] = tool.methods; + const keys = Object.keys(methods); + expect(keys).toEqual(["API"]); + + const [tool] = Object.values(methods); + expect(methods.API).toHaveLength(1); + const [method] = tool; expect(method.name).toBe("uploadPetPhoto"); expect(method.description).toContain("Upload a photo for a pet"); @@ -139,12 +141,12 @@ describe("OpenAPI Multipart Form Parser", () => { }; const converter = new OpenAPIToMCPConverter(spec); - const { tools } = converter.convertToMCPTools(); - expect(Object.keys(tools)).toHaveLength(1); + const { methods } = converter.convertToMCPTools(); + expect(Object.keys(methods)).toHaveLength(1); - const [tool] = Object.values(tools); - expect(tool.methods).toHaveLength(1); - const [method] = tool.methods; + const [tool] = Object.values(methods); + expect(tool).toHaveLength(1); + const [method] = tool; expect(method.name).toBe("uploadPetDocuments"); expect(method.description).toContain("Upload multiple documents"); @@ -248,12 +250,12 @@ describe("OpenAPI Multipart Form Parser", () => { }; const converter = new OpenAPIToMCPConverter(spec); - const { tools } = converter.convertToMCPTools(); - expect(Object.keys(tools)).toHaveLength(1); + const { methods } = converter.convertToMCPTools(); + expect(Object.keys(methods)).toHaveLength(1); - const [tool] = Object.values(tools); - expect(tool.methods).toHaveLength(1); - const [method] = tool.methods; + const [tool] = Object.values(methods); + expect(tool).toHaveLength(1); + const [method] = tool; expect(method.name).toBe("updatePetProfile"); expect(method.description).toContain("Update pet profile"); @@ -367,9 +369,9 @@ describe("OpenAPI Multipart Form Parser", () => { }; const converter = new OpenAPIToMCPConverter(spec); - const { tools } = converter.convertToMCPTools(); - const [tool] = Object.values(tools); - const [method] = tool.methods; + const { methods } = converter.convertToMCPTools(); + const [tool] = Object.values(methods); + const [method] = tool; expect(method.name).toBe("updatePetMetadata"); expect(method.inputSchema.required).toContain("id"); @@ -475,9 +477,9 @@ describe("OpenAPI Multipart Form Parser", () => { }; const converter = new OpenAPIToMCPConverter(spec); - const { tools } = converter.convertToMCPTools(); - const [tool] = Object.values(tools); - const [method] = tool.methods; + const { methods } = converter.convertToMCPTools(); + const [tool] = Object.values(methods); + const [method] = tool; expect(method.name).toBe("addMedicalRecord"); expect(method.inputSchema.required).toContain("id"); @@ -571,9 +573,9 @@ describe("OpenAPI Multipart Form Parser", () => { }; const converter = new OpenAPIToMCPConverter(spec); - const { tools } = converter.convertToMCPTools(); - const [tool] = Object.values(tools); - const [method] = tool.methods; + const { methods } = converter.convertToMCPTools(); + const [tool] = Object.values(methods); + const [method] = tool; expect(method.name).toBe("addPetContent"); expect(method.inputSchema.required).toContain("id"); diff --git a/src/openapi/__tests__/parser.test.ts b/src/openapi/__tests__/parser.test.ts index 70cbafe..ac6225d 100644 --- a/src/openapi/__tests__/parser.test.ts +++ b/src/openapi/__tests__/parser.test.ts @@ -10,13 +10,7 @@ interface ToolMethod { outputSchema?: any; } -interface Tool { - methods: ToolMethod[]; -} - -interface Tools { - [key: string]: Tool; -} +type Tools = Record; // Helper function to verify tool method structure without checking the exact Zod schema function verifyToolMethod(actual: ToolMethod, expected: any, toolName: string) { @@ -36,8 +30,8 @@ function verifyToolMethod(actual: ToolMethod, expected: any, toolName: string) { function verifyTools(actual: Tools, expected: any) { expect(Object.keys(actual)).toEqual(Object.keys(expected)); for (const [key, value] of Object.entries(actual)) { - expect(value.methods.length).toBe(expected[key].methods.length); - value.methods.forEach((method: ToolMethod, index: number) => { + expect(value.length).toBe(expected[key].methods.length); + value.forEach((method: ToolMethod, index: number) => { verifyToolMethod(method, expected[key].methods[index], key); }); } @@ -135,13 +129,13 @@ describe("OpenAPIToMCPConverter", () => { it("converts simple OpenAPI paths to MCP tools", () => { const converter = new OpenAPIToMCPConverter(sampleSpec); - const { tools, openApiLookup } = converter.convertToMCPTools(); + const { methods, openApiLookup } = converter.convertToMCPTools(); - expect(tools).toHaveProperty("API"); - expect(tools.API.methods).toHaveLength(1); + expect(methods).toHaveProperty("API"); + expect(methods.API).toHaveLength(1); expect(Object.keys(openApiLookup)).toHaveLength(1); - const getPetMethod = tools.API.methods.find((m) => m.name === "getPet"); + const getPetMethod = methods.API.find((m) => m.name === "getPet"); expect(getPetMethod).toBeDefined(); const params = getParamsFromSchema(getPetMethod!); @@ -199,9 +193,9 @@ describe("OpenAPIToMCPConverter", () => { }; const converter = new OpenAPIToMCPConverter(specWithLongName); - const { tools } = converter.convertToMCPTools(); + const { methods } = converter.convertToMCPTools(); - const longNameMethod = tools.API.methods.find((m) => m.name.startsWith("a".repeat(59))); + const longNameMethod = methods.API.find((m) => m.name.startsWith("a".repeat(59))); expect(longNameMethod).toBeDefined(); expect(longNameMethod!.name.length).toBeLessThanOrEqual(64); }); @@ -381,9 +375,9 @@ describe("OpenAPIToMCPConverter", () => { it("converts operations with referenced parameters", () => { const converter = new OpenAPIToMCPConverter(complexSpec); - const { tools } = converter.convertToMCPTools(); + const { methods } = converter.convertToMCPTools(); - const getPetMethod = tools.API.methods.find((m) => m.name === "getPet"); + const getPetMethod = methods.API.find((m) => m.name === "getPet"); expect(getPetMethod).toBeDefined(); const params = getParamsFromSchema(getPetMethod!); expect(params).toContainEqual({ @@ -396,9 +390,9 @@ describe("OpenAPIToMCPConverter", () => { it("converts operations with query parameters", () => { const converter = new OpenAPIToMCPConverter(complexSpec); - const { tools } = converter.convertToMCPTools(); + const { methods } = converter.convertToMCPTools(); - const listPetsMethod = tools.API.methods.find((m) => m.name === "listPets"); + const listPetsMethod = methods.API.find((m) => m.name === "listPets"); expect(listPetsMethod).toBeDefined(); const params = getParamsFromSchema(listPetsMethod!); @@ -412,9 +406,9 @@ describe("OpenAPIToMCPConverter", () => { it("converts operations with array responses", () => { const converter = new OpenAPIToMCPConverter(complexSpec); - const { tools } = converter.convertToMCPTools(); + const { methods } = converter.convertToMCPTools(); - const listPetsMethod = tools.API.methods.find((m) => m.name === "listPets"); + const listPetsMethod = methods.API.find((m) => m.name === "listPets"); expect(listPetsMethod).toBeDefined(); const returnType = getReturnType(listPetsMethod!); @@ -427,9 +421,9 @@ describe("OpenAPIToMCPConverter", () => { it("converts operations with request bodies using $ref", () => { const converter = new OpenAPIToMCPConverter(complexSpec); - const { tools } = converter.convertToMCPTools(); + const { methods } = converter.convertToMCPTools(); - const createPetMethod = tools.API.methods.find((m) => m.name === "createPet"); + const createPetMethod = methods.API.find((m) => m.name === "createPet"); expect(createPetMethod).toBeDefined(); const params = getParamsFromSchema(createPetMethod!); @@ -447,9 +441,9 @@ describe("OpenAPIToMCPConverter", () => { it("converts operations with referenced error responses", () => { const converter = new OpenAPIToMCPConverter(complexSpec); - const { tools } = converter.convertToMCPTools(); + const { methods } = converter.convertToMCPTools(); - const getPetMethod = tools.API.methods.find((m) => m.name === "getPet"); + const getPetMethod = methods.API.find((m) => m.name === "getPet"); expect(getPetMethod).toBeDefined(); // We just check that the description includes the error references now. @@ -458,9 +452,9 @@ describe("OpenAPIToMCPConverter", () => { it("handles recursive schema references without expanding them", () => { const converter = new OpenAPIToMCPConverter(complexSpec); - const { tools } = converter.convertToMCPTools(); + const { methods } = converter.convertToMCPTools(); - const createPetMethod = tools.API.methods.find((m) => m.name === "createPet"); + const createPetMethod = methods.API.find((m) => m.name === "createPet"); expect(createPetMethod).toBeDefined(); const params = getParamsFromSchema(createPetMethod!); @@ -470,14 +464,14 @@ describe("OpenAPIToMCPConverter", () => { it("converts all operations correctly respecting $ref usage", () => { const converter = new OpenAPIToMCPConverter(complexSpec); - const { tools } = converter.convertToMCPTools(); + const { methods } = converter.convertToMCPTools(); - expect(tools.API.methods).toHaveLength(4); + expect(methods.API).toHaveLength(4); - const methodNames = tools.API.methods.map((m) => m.name); + const methodNames = methods.API.map((m) => m.name); expect(methodNames).toEqual(expect.arrayContaining(["listPets", "createPet", "getPet", "updatePet"])); - tools.API.methods.forEach((method) => { + methods.API.forEach((method) => { expect(method).toHaveProperty("name"); expect(method).toHaveProperty("description"); expect(method).toHaveProperty("inputSchema"); @@ -697,9 +691,9 @@ describe("OpenAPIToMCPConverter", () => { it("handles deeply nested object references", () => { const converter = new OpenAPIToMCPConverter(nestedSpec); - const { tools } = converter.convertToMCPTools(); + const { methods } = converter.convertToMCPTools(); - const getOrgMethod = tools.API.methods.find((m) => m.name === "getOrganization"); + const getOrgMethod = methods.API.find((m) => m.name === "getOrganization"); expect(getOrgMethod).toBeDefined(); const params = getParamsFromSchema(getOrgMethod!); @@ -729,9 +723,9 @@ describe("OpenAPIToMCPConverter", () => { it("handles recursive array references without requiring expansion", () => { const converter = new OpenAPIToMCPConverter(nestedSpec); - const { tools } = converter.convertToMCPTools(); + const { methods } = converter.convertToMCPTools(); - const updateDeptMethod = tools.API.methods.find((m) => m.name === "updateDepartment"); + const updateDeptMethod = methods.API.find((m) => m.name === "updateDepartment"); expect(updateDeptMethod).toBeDefined(); const params = getParamsFromSchema(updateDeptMethod!); @@ -746,9 +740,9 @@ describe("OpenAPIToMCPConverter", () => { it("handles complex nested object hierarchies without expansion", () => { const converter = new OpenAPIToMCPConverter(nestedSpec); - const { tools } = converter.convertToMCPTools(); + const { methods } = converter.convertToMCPTools(); - const getDeptMethod = tools.API.methods.find((m) => m.name === "getDepartment"); + const getDeptMethod = methods.API.find((m) => m.name === "getDepartment"); expect(getDeptMethod).toBeDefined(); const params = getParamsFromSchema(getDeptMethod!); @@ -781,9 +775,9 @@ describe("OpenAPIToMCPConverter", () => { it("handles schema with mixed primitive and reference types without expansion", () => { const converter = new OpenAPIToMCPConverter(nestedSpec); - const { tools } = converter.convertToMCPTools(); + const { methods } = converter.convertToMCPTools(); - const updateDeptMethod = tools.API.methods.find((m) => m.name === "updateDepartment"); + const updateDeptMethod = methods.API.find((m) => m.name === "updateDepartment"); expect(updateDeptMethod).toBeDefined(); const params = getParamsFromSchema(updateDeptMethod!); @@ -798,14 +792,14 @@ describe("OpenAPIToMCPConverter", () => { it("converts all operations with complex schemas correctly respecting $ref", () => { const converter = new OpenAPIToMCPConverter(nestedSpec); - const { tools } = converter.convertToMCPTools(); + const { methods } = converter.convertToMCPTools(); - expect(tools.API.methods).toHaveLength(3); + expect(methods.API).toHaveLength(3); - const methodNames = tools.API.methods.map((m) => m.name); + const methodNames = methods.API.map((m) => m.name); expect(methodNames).toEqual(expect.arrayContaining(["getOrganization", "getDepartment", "updateDepartment"])); - tools.API.methods.forEach((method) => { + methods.API.forEach((method) => { expect(method).toHaveProperty("name"); expect(method).toHaveProperty("description"); expect(method).toHaveProperty("inputSchema"); @@ -939,13 +933,13 @@ describe("OpenAPIToMCPConverter", () => { it("should include delete operations in MCP tools", () => { const converter = new OpenAPIToMCPConverter(deleteSpec); - const { tools, openApiLookup } = converter.convertToMCPTools(); + const { methods, openApiLookup } = converter.convertToMCPTools(); - expect(tools).toHaveProperty("API"); - expect(tools.API.methods).toHaveLength(2); + expect(methods).toHaveProperty("API"); + expect(methods.API).toHaveLength(2); - const deleteObjectMethod = tools.API.methods.find((m) => m.name === "delete-object"); - const removeListObjectMethod = tools.API.methods.find((m) => m.name === "remove-list-object"); + const deleteObjectMethod = methods.API.find((m) => m.name === "delete-object"); + const removeListObjectMethod = methods.API.find((m) => m.name === "remove-list-object"); expect(deleteObjectMethod).toBeDefined(); expect(removeListObjectMethod).toBeDefined(); @@ -985,9 +979,9 @@ describe("OpenAPIToMCPConverter", () => { it("should handle delete operations with proper error responses", () => { const converter = new OpenAPIToMCPConverter(deleteSpec); - const { tools } = converter.convertToMCPTools(); + const { methods } = converter.convertToMCPTools(); - const deleteObjectMethod = tools.API.methods.find((m) => m.name === "delete-object"); + const deleteObjectMethod = methods.API.find((m) => m.name === "delete-object"); expect(deleteObjectMethod?.description).toContain("Delete an object"); }); }); @@ -1586,10 +1580,10 @@ describe("OpenAPIToMCPConverter - Additional Complex Tests", () => { it.each(cases)("$name", ({ input, expected }) => { const converter = new OpenAPIToMCPConverter(input); - const { tools, openApiLookup } = converter.convertToMCPTools(); + const { methods, openApiLookup } = converter.convertToMCPTools(); // Use the custom verification instead of direct equality - verifyTools(tools, expected.tools); + verifyTools(methods, expected.tools); expect(openApiLookup).toEqual(expected.openApiLookup); }); }); diff --git a/src/openapi/parser.ts b/src/openapi/parser.ts index 0fad43d..ec0674d 100644 --- a/src/openapi/parser.ts +++ b/src/openapi/parser.ts @@ -1,13 +1,17 @@ -import type { Tool } from "@anthropic-ai/sdk/resources/messages/messages"; +import type { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/messages/messages"; +import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js"; import type { JSONSchema7 as IJsonSchema } from "json-schema"; import type { ChatCompletionTool } from "openai/resources/chat/completions"; import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; -type NewToolMethod = { +type McpToolAnnotations = McpTool["annotations"]; + +export type ToolMethod = { name: string; description: string; inputSchema: IJsonSchema & { type: "object" }; outputSchema?: IJsonSchema; + annotations?: McpToolAnnotations; }; type FunctionParameters = { @@ -21,7 +25,10 @@ export class OpenAPIToMCPConverter { private schemaCache: Record = {}; private nameCounter: number = 0; - constructor(private openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document) {} + constructor( + private openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document, + private options: { skipToolNamePrefix?: boolean; stripErrResponseDescriptions?: boolean } = {}, + ) {} /** * Resolve a $ref reference to its schema in the openApiSpec. @@ -313,19 +320,19 @@ export class OpenAPIToMCPConverter { } convertToMCPTools(): { - tools: Record; + methods: Record; openApiLookup: Record; - zip: Record; + zip: Record; } { const apiName = "API"; const openApiLookup: Record = {}; - const tools: Record = { - [apiName]: { methods: [] }, + const methods: Record = { + [apiName]: [], }; const zip: Record< string, - { openApi: OpenAPIV3.OperationObject & { method: string; path: string }; mcp: NewToolMethod } + { openApi: OpenAPIV3.OperationObject & { method: string; path: string }; mcp: ToolMethod } > = {}; for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) { if (!pathItem) continue; @@ -339,14 +346,15 @@ export class OpenAPIToMCPConverter { // convert name to kebab-case to conform mcp tool naming convention const uniqueName = this.ensureUniqueName(mcpMethod.name).replaceAll("_", "-"); mcpMethod.name = uniqueName; - tools[apiName]!.methods.push(mcpMethod); - openApiLookup[apiName + "-" + uniqueName] = { ...operation, method, path }; - zip[apiName + "-" + uniqueName] = { openApi: { ...operation, method, path }, mcp: mcpMethod }; + methods[apiName]!.push(mcpMethod); + const toolName = this.options.skipToolNamePrefix ? uniqueName : `${apiName}-${uniqueName}`; + openApiLookup[toolName] = { ...operation, method, path }; + zip[toolName] = { openApi: { ...operation, method, path }, mcp: mcpMethod }; } } } - return { tools, openApiLookup, zip }; + return { methods, openApiLookup, zip }; } /** @@ -381,8 +389,8 @@ export class OpenAPIToMCPConverter { /** * Convert the OpenAPI spec to Anthropic's Tool format */ - convertToAnthropicTools(): Tool[] { - const tools: Tool[] = []; + convertToAnthropicTools(): AnthropicTool[] { + const tools: AnthropicTool[] = []; for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) { if (!pathItem) continue; @@ -392,10 +400,10 @@ export class OpenAPIToMCPConverter { if (!this.isOperation(method, operation) || operation.tags?.includes("Auth")) continue; const parameters = this.convertOperationToJsonSchema(operation, method, path); - const tool: Tool = { + const tool: AnthropicTool = { name: operation.operationId!, description: operation.summary || operation.description || "", - input_schema: parameters as Tool["input_schema"], + input_schema: parameters as AnthropicTool["input_schema"], }; tools.push(tool); } @@ -537,7 +545,7 @@ export class OpenAPIToMCPConverter { operation: OpenAPIV3.OperationObject, method: string, path: string, - ): NewToolMethod | null { + ): ToolMethod | null { if (!operation.operationId) { console.warn(`Operation without operationId at ${method} ${path}`); return null; @@ -626,7 +634,7 @@ export class OpenAPIToMCPConverter { // Build description including error responses let description = operation.summary || operation.description || ""; - if (operation.responses) { + if (operation.responses && !this.options.stripErrResponseDescriptions) { const errorResponses = Object.entries(operation.responses) .filter(([code]) => code.startsWith("4") || code.startsWith("5")) .map(([code, response]) => { @@ -643,6 +651,8 @@ export class OpenAPIToMCPConverter { // Extract return type (output schema) const outputSchema = this.extractResponseType(operation.responses); + const annotations = this.guessToolAnnotations(method, methodName); + // Generate Zod schema from input schema try { // const zodSchemaStr = jsonSchemaToZod(inputSchema, { module: "cjs" }) @@ -654,6 +664,7 @@ export class OpenAPIToMCPConverter { name: methodName, description, inputSchema, + annotations, ...(outputSchema ? { outputSchema } : {}), }; } catch (error) { @@ -668,6 +679,16 @@ export class OpenAPIToMCPConverter { } } + private guessToolAnnotations(method: string, methodName: string): McpToolAnnotations { + const [verb] = methodName.split("_"); + return { + destructiveHint: method === "delete", + idempotentHint: method === "get" || method === "patch" || verb === "search", + readOnlyHint: method === "get" || verb === "search", + openWorldHint: true, + }; + } + private extractResponseType(responses: OpenAPIV3.ResponsesObject | undefined): IJsonSchema | null { // Look for a success response const successResponse = responses?.["200"] || responses?.["201"] || responses?.["202"] || responses?.["204"]; diff --git a/src/utils/__tests__/base-url.test.ts b/src/utils/__tests__/base-url.test.ts index 796608e..d6586eb 100644 --- a/src/utils/__tests__/base-url.test.ts +++ b/src/utils/__tests__/base-url.test.ts @@ -1,10 +1,20 @@ import { OpenAPIV3 } from "openapi-types"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { determineBaseUrl, getDefaultSpecUrl, parseBaseUrlFromEnv } from "../base-url"; +import { ConfigEnv, ENV_KEYS } from "../config.js"; describe("base-url utilities", () => { const originalEnv = process.env; + async function loadBaseUrl(env: ConfigEnv = {}) { + vi.resetModules(); + ENV_KEYS.forEach((k) => delete process.env[k]); + Object.entries(env).forEach(([k, v]) => { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + }); + return import("../base-url.js"); + } + beforeEach(() => { process.env = { ...originalEnv }; }); @@ -13,49 +23,6 @@ describe("base-url utilities", () => { process.env = originalEnv; }); - describe("parseBaseUrlFromEnv", () => { - it("should parse valid HTTP URL from env", () => { - process.env.ANYTYPE_API_BASE_URL = "http://localhost:31012"; - expect(parseBaseUrlFromEnv()).toBe("http://localhost:31012"); - }); - - it("should parse valid HTTPS URL from env", () => { - process.env.ANYTYPE_API_BASE_URL = "https://api.example.com:8080"; - expect(parseBaseUrlFromEnv()).toBe("https://api.example.com:8080"); - }); - - it("should strip path from URL and return origin only", () => { - process.env.ANYTYPE_API_BASE_URL = "http://localhost:31012/api/v1"; - expect(parseBaseUrlFromEnv()).toBe("http://localhost:31012"); - }); - - it("should return null when env var is not set", () => { - delete process.env.ANYTYPE_API_BASE_URL; - expect(parseBaseUrlFromEnv()).toBeNull(); - }); - - it("should return null and warn on invalid URL", () => { - const consoleSpy = vi.spyOn(console, "warn"); - process.env.ANYTYPE_API_BASE_URL = "not-a-valid-url"; - - expect(parseBaseUrlFromEnv()).toBeNull(); - expect(consoleSpy).toHaveBeenCalledWith( - "Failed to parse ANYTYPE_API_BASE_URL environment variable:", - expect.any(Error), - ); - }); - - it("should return null and warn on unsupported protocol", () => { - const consoleSpy = vi.spyOn(console, "warn"); - process.env.ANYTYPE_API_BASE_URL = "ftp://localhost:31012"; - - expect(parseBaseUrlFromEnv()).toBeNull(); - expect(consoleSpy).toHaveBeenCalledWith( - "ANYTYPE_API_BASE_URL must use http:// or https:// protocol, got: ftp:. Ignoring and using fallback.", - ); - }); - }); - describe("determineBaseUrl", () => { const mockOpenApiSpec: OpenAPIV3.Document = { openapi: "3.0.0", @@ -67,70 +34,48 @@ describe("base-url utilities", () => { paths: {}, }; - it("should prioritize ANYTYPE_API_BASE_URL over spec servers", () => { - const consoleSpy = vi.spyOn(console, "error"); - process.env.ANYTYPE_API_BASE_URL = "http://localhost:31012"; + it("should prioritize ANYTYPE_API_BASE_URL over spec servers", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { determineBaseUrl } = await loadBaseUrl({ ANYTYPE_API_BASE_URL: "http://localhost:31012" }); expect(determineBaseUrl(mockOpenApiSpec)).toBe("http://localhost:31012"); expect(consoleSpy).toHaveBeenCalledWith("Using base URL from ANYTYPE_API_BASE_URL: http://localhost:31012"); + consoleSpy.mockRestore(); }); - it("should use spec servers[0].url when env var is not set", () => { - const consoleSpy = vi.spyOn(console, "error"); - delete process.env.ANYTYPE_API_BASE_URL; + it("should use spec servers[0].url when env var is not set", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { determineBaseUrl } = await loadBaseUrl(); expect(determineBaseUrl(mockOpenApiSpec)).toBe("http://localhost:3000"); expect(consoleSpy).toHaveBeenCalledWith("Using base URL from OpenAPI spec: http://localhost:3000"); + consoleSpy.mockRestore(); }); - it("should use default fallback when neither env var nor spec servers are available", () => { - const consoleSpy = vi.spyOn(console, "error"); - delete process.env.ANYTYPE_API_BASE_URL; - const specWithoutServers = { - ...mockOpenApiSpec, - servers: undefined, - }; + it("should use default fallback when neither env var nor spec servers are available", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { determineBaseUrl } = await loadBaseUrl(); - expect(determineBaseUrl(specWithoutServers)).toBe("http://127.0.0.1:31009"); + expect(determineBaseUrl({ ...mockOpenApiSpec, servers: undefined })).toBe("http://127.0.0.1:31009"); expect(consoleSpy).toHaveBeenCalledWith("Using default base URL: http://127.0.0.1:31009"); + consoleSpy.mockRestore(); }); - it("should use default fallback when spec is not provided", () => { - const consoleSpy = vi.spyOn(console, "error"); - delete process.env.ANYTYPE_API_BASE_URL; + it("should use default fallback when spec is not provided", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { determineBaseUrl } = await loadBaseUrl(); expect(determineBaseUrl()).toBe("http://127.0.0.1:31009"); expect(consoleSpy).toHaveBeenCalledWith("Using default base URL: http://127.0.0.1:31009"); + consoleSpy.mockRestore(); }); - it("should fallback to spec servers when env var is invalid", () => { - const consoleSpy = vi.spyOn(console, "error"); - process.env.ANYTYPE_API_BASE_URL = "invalid-url"; - - expect(determineBaseUrl(mockOpenApiSpec)).toBe("http://localhost:3000"); - expect(consoleSpy).toHaveBeenCalledWith("Using base URL from OpenAPI spec: http://localhost:3000"); - }); - }); - - describe("getDefaultSpecUrl", () => { - it("should use ANYTYPE_API_BASE_URL with /docs/openapi.json suffix when set", () => { - process.env.ANYTYPE_API_BASE_URL = "http://localhost:31012"; - expect(getDefaultSpecUrl()).toBe("http://localhost:31012/docs/openapi.json"); - }); - - it("should strip path from endpoint before adding suffix", () => { - process.env.ANYTYPE_API_BASE_URL = "http://localhost:31012/some/path"; - expect(getDefaultSpecUrl()).toBe("http://localhost:31012/docs/openapi.json"); - }); + it("should strip path from ANYTYPE_API_BASE_URL, keeping origin only", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { determineBaseUrl } = await loadBaseUrl({ ANYTYPE_API_BASE_URL: "http://localhost:31012/some/path" }); - it("should return default URL when env var is not set", () => { - delete process.env.ANYTYPE_API_BASE_URL; - expect(getDefaultSpecUrl()).toBe("http://127.0.0.1:31009/docs/openapi.json"); - }); - - it("should return default URL when env var is invalid", () => { - process.env.ANYTYPE_API_BASE_URL = "invalid-url"; - expect(getDefaultSpecUrl()).toBe("http://127.0.0.1:31009/docs/openapi.json"); + expect(determineBaseUrl(mockOpenApiSpec)).toBe("http://localhost:31012"); + consoleSpy.mockRestore(); }); }); }); diff --git a/src/utils/__tests__/proxy-config.test.ts b/src/utils/__tests__/proxy-config.test.ts new file mode 100644 index 0000000..19dd60f --- /dev/null +++ b/src/utils/__tests__/proxy-config.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Config, ConfigEnv, ENV_KEYS } from "../config.js"; + +describe("proxy-config utilities", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + async function loadConfig(env: ConfigEnv = {}) { + vi.resetModules(); + ENV_KEYS.forEach((k) => delete process.env[k]); + Object.entries(env).forEach(([k, v]) => { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + }); + const { getConfig } = await import("../config.js"); + return getConfig(); + } + + // ─── transport ─────────────────────────────────────────────────────────────── + + describe("transport", () => { + it("defaults to stdio", async () => { + const config = await loadConfig(); + expect(config.transport).toEqual({ type: "stdio" }); + }); + + it("stdio when MCP_TRANSPORT is unset", async () => { + const config = await loadConfig({}); + expect(config.transport.type).toBe("stdio"); + }); + + it("http with defaults when MCP_TRANSPORT=http", async () => { + const config = await loadConfig({ MCP_TRANSPORT: "http" }); + expect(config.transport).toEqual({ + type: "http", + host: "127.0.0.1", + port: 3666, + passthroughHeaders: ["authorization", "anytype-version"], + }); + }); + + it("http with custom host and port", async () => { + const config = await loadConfig({ MCP_TRANSPORT: "http", MCP_HOST: "0.0.0.0", MCP_PORT: "8080" }); + expect(config.transport).toEqual({ + type: "http", + host: "0.0.0.0", + port: 8080, + passthroughHeaders: ["authorization", "anytype-version"], + }); + }); + + it("coerces port string to number", async () => { + const config = await loadConfig({ MCP_TRANSPORT: "http", MCP_PORT: "4000" }); + expect((config.transport as { type: "http"; port: number }).port).toBe(4000); + }); + + it("throws on port below 1024", async () => { + await expect(loadConfig({ MCP_TRANSPORT: "http", MCP_PORT: "80" })).rejects.toThrow(); + }); + + it("throws on port above 65535", async () => { + await expect(loadConfig({ MCP_TRANSPORT: "http", MCP_PORT: "99999" })).rejects.toThrow(); + }); + + it("throws on non-numeric port", async () => { + await expect(loadConfig({ MCP_TRANSPORT: "http", MCP_PORT: "not-a-port" })).rejects.toThrow(); + }); + }); + + // ─── httpClient.baseUrl ─────────────────────────────────────────────────────── + + describe("httpClient.baseUrl", () => { + it("is undefined when not set", async () => { + const config = await loadConfig(); + expect(config.httpClient.baseUrl).toBeUndefined(); + }); + + it("parses http url and returns origin", async () => { + const config = await loadConfig({ ANYTYPE_API_BASE_URL: "http://127.0.0.1:31009/some/path" }); + expect(config.httpClient.baseUrl).toBe("http://127.0.0.1:31009"); + }); + + it("parses https url and returns origin", async () => { + const config = await loadConfig({ ANYTYPE_API_BASE_URL: "https://api.example.com/v1" }); + expect(config.httpClient.baseUrl).toBe("https://api.example.com"); + }); + + it("strips path, query, and fragment", async () => { + const config = await loadConfig({ ANYTYPE_API_BASE_URL: "http://localhost:3000/path?q=1#frag" }); + expect(config.httpClient.baseUrl).toBe("http://localhost:3000"); + }); + + it("throws on ftp protocol", async () => { + await expect(loadConfig({ ANYTYPE_API_BASE_URL: "ftp://example.com" })).rejects.toThrow(); + }); + + it("throws on ws protocol", async () => { + await expect(loadConfig({ ANYTYPE_API_BASE_URL: "ws://example.com" })).rejects.toThrow(); + }); + + it("throws on invalid url", async () => { + await expect(loadConfig({ ANYTYPE_API_BASE_URL: "not-a-url" })).rejects.toThrow(); + }); + }); + + // ─── httpClient.headers ─────────────────────────────────────────────────────── + + describe("httpClient.headers", () => { + it("defaults to empty object", async () => { + const config = await loadConfig(); + expect(config.httpClient.headers).toEqual({}); + }); + + it("parses valid JSON headers", async () => { + const config = await loadConfig({ + OPENAPI_MCP_HEADERS: JSON.stringify({ Authorization: "Bearer token", "Anytype-Version": "1.0" }), + }); + expect(config.httpClient.headers).toEqual({ Authorization: "Bearer token", "Anytype-Version": "1.0" }); + }); + + it("returns empty object on invalid JSON", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const config = await loadConfig({ OPENAPI_MCP_HEADERS: "{not valid json" }); + expect(config.httpClient.headers).toEqual({}); + consoleSpy.mockRestore(); + }); + + it("returns empty object when value is non-object JSON", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const config = await loadConfig({ OPENAPI_MCP_HEADERS: '"just-a-string"' }); + expect(config.httpClient.headers).toEqual({}); + consoleSpy.mockRestore(); + }); + + it("returns empty object when header values are non-string", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const config = await loadConfig({ OPENAPI_MCP_HEADERS: JSON.stringify({ key: 123 }) }); + expect(config.httpClient.headers).toEqual({}); + consoleSpy.mockRestore(); + }); + + it("logs error on parse failure", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + await loadConfig({ OPENAPI_MCP_HEADERS: "bad" }); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("OPENAPI_MCP_HEADERS")); + consoleSpy.mockRestore(); + }); + }); + + // ─── transport.passthroughHeaders ──────────────────────────────────────────── + + describe("transport.passthroughHeaders (http only)", () => { + it("defaults to DEFAULT_PASSTHROUGH_HEADERS", async () => { + const config = await loadConfig({ MCP_TRANSPORT: "http" }); + expect((config.transport as { type: "http"; passthroughHeaders: string[] }).passthroughHeaders).toEqual([ + "authorization", + "anytype-version", + ]); + }); + + it("parses custom comma-separated headers", async () => { + const config = await loadConfig({ MCP_TRANSPORT: "http", MCP_PASSTHROUGH_HEADERS: "x-custom, x-other" }); + expect((config.transport as { type: "http"; passthroughHeaders: string[] }).passthroughHeaders).toEqual([ + "x-custom", + "x-other", + ]); + }); + }); + + // ─── combined ───────────────────────────────────────────────────────────────── + + describe("combined config", () => { + it("parses all fields together", async () => { + const config = await loadConfig({ + MCP_TRANSPORT: "http", + MCP_HOST: "0.0.0.0", + MCP_PORT: "8888", + ANYTYPE_API_BASE_URL: "http://127.0.0.1:31009", + OPENAPI_MCP_HEADERS: JSON.stringify({ Authorization: "Bearer x" }), + }); + expect(config).toEqual({ + transport: { + type: "http", + host: "0.0.0.0", + port: 8888, + passthroughHeaders: ["authorization", "anytype-version"], + }, + httpClient: { baseUrl: "http://127.0.0.1:31009", headers: { Authorization: "Bearer x" } }, + }); + }); + }); +}); diff --git a/src/utils/base-url.ts b/src/utils/base-url.ts index 89d2d52..baeced8 100644 --- a/src/utils/base-url.ts +++ b/src/utils/base-url.ts @@ -1,30 +1,8 @@ -import { URL } from "node:url"; import { OpenAPIV3 } from "openapi-types"; +import { getConfig } from "./config"; -/** - * Parses the ANYTYPE_API_BASE_URL environment variable and returns the origin. - * Returns null if not set, invalid, or uses an unsupported protocol. - */ -export function parseBaseUrlFromEnv(): string | null { - const endpoint = process.env.ANYTYPE_API_BASE_URL; - if (!endpoint) { - return null; - } - - try { - const url = new URL(endpoint); - if (url.protocol !== "http:" && url.protocol !== "https:") { - console.warn( - `ANYTYPE_API_BASE_URL must use http:// or https:// protocol, got: ${url.protocol}. Ignoring and using fallback.`, - ); - return null; - } - return url.origin; - } catch (error) { - console.warn("Failed to parse ANYTYPE_API_BASE_URL environment variable:", error); - return null; - } -} +export const DEFAULT_BASE_URL = "http://127.0.0.1:31009"; +const DEFAULT_SPEC_PATH = "/docs/openapi.json"; /** * Determines the base URL using priority order: @@ -34,10 +12,10 @@ export function parseBaseUrlFromEnv(): string | null { */ export function determineBaseUrl(openApiSpec?: OpenAPIV3.Document): string { // Priority 1: Environment variable - const envEndpoint = parseBaseUrlFromEnv(); - if (envEndpoint) { - console.error(`Using base URL from ANYTYPE_API_BASE_URL: ${envEndpoint}`); - return envEndpoint; + const { baseUrl } = getConfig().httpClient; + if (baseUrl) { + console.error(`Using base URL from ANYTYPE_API_BASE_URL: ${baseUrl}`); + return baseUrl; } // Priority 2: OpenAPI spec servers[0].url @@ -48,20 +26,25 @@ export function determineBaseUrl(openApiSpec?: OpenAPIV3.Document): string { } // Priority 3: Default fallback - const defaultUrl = "http://127.0.0.1:31009"; - console.error(`Using default base URL: ${defaultUrl}`); - return defaultUrl; + console.error(`Using default base URL: ${DEFAULT_BASE_URL}`); + return DEFAULT_BASE_URL; } +let specPathOverride: string | undefined; + /** - * Gets the default OpenAPI spec URL. - * If ANYTYPE_API_BASE_URL is set, uses it with /docs/openapi.json suffix. - * Otherwise, returns the default spec URL. + * Sets the spec path explicitly (from CLI params). */ -export function getDefaultSpecUrl(): string { - const endpoint = parseBaseUrlFromEnv(); - if (endpoint) { - return `${endpoint}/docs/openapi.json`; - } - return "http://127.0.0.1:31009/docs/openapi.json"; +export function overrideSpecPath(specPath?: string) { + specPathOverride = specPath; +} + +/** + * Returns the spec path resolved in the following order: + * 1. From overridden, if any ({@link overrideSpecPath}), + * 2. From config, if any + * 3. Default one. + */ +export function resolveSpecPath() { + return specPathOverride ?? `${getConfig().httpClient.baseUrl ?? DEFAULT_BASE_URL}${DEFAULT_SPEC_PATH}`; } diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..d35da50 --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,117 @@ +import { URL } from "node:url"; +import { z } from "zod"; + +export const ENV_KEYS = [ + "MCP_TRANSPORT", + "MCP_HOST", + "MCP_PORT", + "MCP_PASSTHROUGH_HEADERS", + "ANYTYPE_API_BASE_URL", + "OPENAPI_MCP_HEADERS", +] as const; + +export type ConfigEnv = Partial>; + +/** + * Headers allowed to be forwarded from MCP HTTP requests to the upstream API. + * Prevents header injection attacks (Host, Content-Length, Transfer-Encoding, etc.). + */ +export const DEFAULT_PASSTHROUGH_HEADERS = ["authorization", "anytype-version"] as const; + +const TransportConfigSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("stdio") }), + z.object({ + type: z.literal("http"), + host: z.string().default("127.0.0.1"), + port: z.coerce.number().int().min(1024).max(65535).default(3666), + /** + * Comma-separated list of inbound MCP HTTP transport header names (lowercase) to forward + * to the upstream API. Defaults to DEFAULT_PASSTHROUGH_HEADERS. + */ + passthroughHeaders: z + .string() + .optional() + .transform((val) => + val + ? val + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean) + : [...DEFAULT_PASSTHROUGH_HEADERS], + ), + }), +]); + +export type TransportConfig = z.infer; + +const HttpClientConfigSchema = z.object({ + /** + * Parses ANYTYPE_API_BASE_URL and returns the origin. + * Falls back to OpenAPI spec servers[0].url, then http://127.0.0.1:31009. + */ + baseUrl: z + .url({ protocol: /^https?$/ }) + .transform((v) => new URL(v).origin) + .optional(), + + /** + * JSON object of headers forwarded to the upstream API on every request. + * Parsed from OPENAPI_MCP_HEADERS. + */ + headers: z + .string() + .optional() + .transform((val) => { + if (!val) return {} as Record; + try { + return z.record(z.string(), z.string()).parse(JSON.parse(val)); + } catch { + console.error("Failed to parse OPENAPI_MCP_HEADERS, ignoring"); + return {} as Record; + } + }), +}); + +export type HttpClientConfig = z.infer; + +/** + * Anytype MCP server config schema. + */ +const ConfigSchema = z.object({ + /** + * MCP Server transport. + * Currently can be either of: stdio (default) and http. + */ + transport: TransportConfigSchema.default({ type: "stdio" }), + + /** + * Target/upstream Anytype OpenAPI client config. + */ + httpClient: HttpClientConfigSchema, +}); + +export type Config = z.infer; + +let config: Config | undefined; + +export function getConfig() { + if (!config) { + config = ConfigSchema.parse({ + transport: + process.env.MCP_TRANSPORT === "http" + ? { + type: "http", + host: process.env.MCP_HOST, + port: process.env.MCP_PORT, + passthroughHeaders: process.env.MCP_PASSTHROUGH_HEADERS, + } + : { type: "stdio" }, + httpClient: { + baseUrl: process.env.ANYTYPE_API_BASE_URL, + headers: process.env.OPENAPI_MCP_HEADERS, + }, + }); + } + + return config; +} diff --git a/tsconfig.json b/tsconfig.json index 17688a0..0743102 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,26 +1,24 @@ { - "compilerOptions": { - "composite": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./build", - "target": "es2021", - "lib": ["es2022"], - "jsx": "react-jsx", - "module": "es2022", - "moduleResolution": "Bundler", - "types": [ - "node" - ], - "resolveJsonModule": true, - "allowJs": true, - "checkJs": false, - "isolatedModules": true, - "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "skipLibCheck": true - }, - "include": [ "test/**/*.ts", "scripts/**/*.ts", "src/**/*.ts", "examples/**/*"] + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./build", + "target": "es2021", + "lib": ["es2022"], + "jsx": "react-jsx", + "module": "es2022", + "moduleResolution": "Bundler", + "types": ["node"], + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["package.json", "test/**/*.ts", "scripts/**/*.ts", "src/**/*.ts", "examples/**/*"] }