diff --git a/src/client/__tests__/http-client.test.ts b/src/client/__tests__/http-client.test.ts index 1d52403..c7d0d8c 100644 --- a/src/client/__tests__/http-client.test.ts +++ b/src/client/__tests__/http-client.test.ts @@ -1,4 +1,5 @@ import { Headers } from "node-fetch"; +import { Buffer } from "node:buffer"; import OpenAPIClientAxios from "openapi-client-axios"; import { OpenAPIV3 } from "openapi-types"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -7,6 +8,7 @@ import { HttpClient } from "../http-client"; // Mock the OpenAPIClientAxios initialization const mockApi = { getPet: vi.fn(), + downloadFile: vi.fn(), testOperation: vi.fn(), complexOperation: vi.fn(), }; @@ -95,6 +97,162 @@ describe("HttpClient", () => { expect(response.headers.get("content-type")).toBe("application/json"); }); + it("requests binary responses as arraybuffers", async () => { + const operation: OpenAPIV3.OperationObject & { method: string; path: string } = { + method: "get", + path: "/files/{fileId}", + operationId: "downloadFile", + parameters: [{ name: "fileId", in: "path", required: true, schema: { type: "string" } }], + responses: { + "200": { + description: "File contents", + content: { + "application/octet-stream": { + schema: { type: "string", format: "binary" }, + }, + }, + }, + }, + }; + const fileBytes = Buffer.from("file contents"); + mockApi.downloadFile.mockResolvedValueOnce({ + data: fileBytes, + status: 200, + headers: { "content-type": "application/pdf" }, + }); + + const response = await client.executeOperation(operation, { fileId: "file-1" }); + + expect(mockApi.downloadFile).toHaveBeenCalledWith({ fileId: "file-1" }, undefined, { + headers: { "Content-Type": null }, + responseType: "arraybuffer", + }); + expect(response.data).toBe(fileBytes); + expect(response.headers.get("content-type")).toBe("application/pdf"); + }); + + it("detects binary responses through local response and schema references", async () => { + const spec: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Test API", version: "1.0.0" }, + paths: { + "/files/{fileId}": { + get: { + operationId: "downloadFile", + responses: { "200": { $ref: "#/components/responses/FileResponse" } }, + }, + }, + }, + components: { + responses: { + FileResponse: { + description: "File contents", + content: { "application/pdf": { schema: { $ref: "#/components/schemas/BinaryFile" } } }, + }, + }, + schemas: { BinaryFile: { type: "string", format: "binary" } }, + }, + }; + const operation = spec.paths["/files/{fileId}"]?.get as OpenAPIV3.OperationObject & { + method: string; + path: string; + }; + const referencedClient = new HttpClient({ baseUrl: "https://api.example.com" }, spec); + mockApi.downloadFile.mockResolvedValueOnce({ data: Buffer.from("pdf"), status: 200, headers: {} }); + + await referencedClient.executeOperation(operation, { fileId: "file-1" }); + + expect(mockApi.downloadFile).toHaveBeenCalledWith( + { fileId: "file-1" }, + undefined, + expect.objectContaining({ responseType: "arraybuffer" }), + ); + }); + + it("decodes successful JSON variants from binary operations", async () => { + const operation: OpenAPIV3.OperationObject & { method: string; path: string } = { + method: "get", + path: "/files/{fileId}", + operationId: "downloadFile", + responses: { + "200": { + description: "File or metadata", + content: { + "application/octet-stream": { schema: { type: "string", format: "binary" } }, + "application/json": { schema: { type: "object" } }, + }, + }, + }, + }; + const metadata = { status: "processing" }; + mockApi.downloadFile.mockResolvedValueOnce({ + data: Buffer.from(JSON.stringify(metadata)), + status: 200, + headers: { "content-type": "application/json" }, + }); + + const response = await client.executeOperation(operation, { fileId: "file-1" }); + + expect(response.data).toEqual(metadata); + }); + + it("decodes structured JSON errors from binary operations", async () => { + const operation: OpenAPIV3.OperationObject & { method: string; path: string } = { + method: "get", + path: "/files/{fileId}", + operationId: "downloadFile", + responses: { + "200": { + description: "File contents", + content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } }, + }, + "404": { description: "File not found" }, + }, + }; + const errorData = { code: "NOT_FOUND", message: "File not found" }; + mockApi.downloadFile.mockRejectedValueOnce({ + response: { + data: Buffer.from(JSON.stringify(errorData)), + status: 404, + statusText: "Not Found", + headers: { "content-type": "application/json; charset=utf-8" }, + }, + }); + + await expect(client.executeOperation(operation, { fileId: "missing" })).rejects.toMatchObject({ + status: 404, + data: errorData, + }); + }); + + it("decodes text errors from ArrayBuffer responses", async () => { + const operation: OpenAPIV3.OperationObject & { method: string; path: string } = { + method: "get", + path: "/files/{fileId}", + operationId: "downloadFile", + responses: { + "200": { + description: "File contents", + content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } }, + }, + }, + }; + const errorBytes = Uint8Array.from(Buffer.from("download unavailable")); + mockApi.downloadFile.mockRejectedValueOnce({ + response: { + data: errorBytes.buffer, + status: 503, + statusText: "Service Unavailable", + headers: { "content-type": "text/plain; charset=utf-8" }, + }, + }); + + await expect(client.executeOperation(operation, { fileId: "file-1" })).rejects.toMatchObject({ + status: 503, + data: "download unavailable", + }); + }); + it("throws error when operation ID is missing", async () => { const operationWithoutId: OpenAPIV3.OperationObject & { method: string; path: string } = { method: "GET", diff --git a/src/client/http-client.ts b/src/client/http-client.ts index 7c4e5ae..df1c9c4 100644 --- a/src/client/http-client.ts +++ b/src/client/http-client.ts @@ -2,6 +2,7 @@ import type { AxiosInstance } from "axios"; import FormData from "form-data"; import fs from "fs"; import { Headers } from "node-fetch"; +import { Buffer } from "node:buffer"; import OpenAPIClientAxios from "openapi-client-axios"; import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; import { isFileUploadParameter } from "../openapi/file-upload"; @@ -32,8 +33,10 @@ export class HttpClientError extends Error { export class HttpClient { private api: Promise; private client: OpenAPIClientAxios; + private openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document; constructor(config: HttpClientConfig, openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document) { + this.openApiSpec = openApiSpec; // @ts-expect-error OpenAPIClientAxios can be imported as default or named export, we handle both cases this.client = new (OpenAPIClientAxios.default ?? OpenAPIClientAxios)({ definition: openApiSpec, @@ -104,6 +107,72 @@ export class HttpClient { return formData; } + /** + * Whether an operation declares a binary success response. Axios otherwise + * decodes response bodies as text, which can corrupt downloaded file bytes. + */ + private hasBinaryResponse(operation: OpenAPIV3.OperationObject): boolean { + const successStatuses = ["200", "201", "202", "203", "204", "206"]; + + return successStatuses.some((status) => { + const response = operation.responses?.[status]; + if (!response) return false; + const responseObject = + "$ref" in response ? this.resolveLocalRef(response.$ref) : response; + if (!responseObject) return false; + + return Object.entries(responseObject.content ?? {}).some(([mediaType, media]) => { + const schema = media.schema; + const schemaObject = + schema && "$ref" in schema ? this.resolveLocalRef(schema.$ref) : schema; + const hasBinarySchema = schemaObject && !("$ref" in schemaObject) && schemaObject.format === "binary"; + + return mediaType === "application/octet-stream" || mediaType.startsWith("image/") || hasBinarySchema; + }); + }); + } + + private resolveLocalRef(ref: string): T | null { + if (!ref.startsWith("#/")) return null; + + let current: unknown = this.openApiSpec; + for (const rawPart of ref.slice(2).split("/")) { + const part = rawPart.replaceAll("~1", "/").replaceAll("~0", "~"); + if (!current || typeof current !== "object" || !(part in current)) return null; + current = (current as Record)[part]; + } + return current as T; + } + + /** + * Axios returns every response as bytes when responseType is arraybuffer, + * including JSON and text errors from binary download operations. + */ + private decodeTextData(data: unknown, headers: Headers): unknown { + let bytes: Buffer | null = null; + if (Buffer.isBuffer(data)) { + bytes = data; + } else if (data instanceof ArrayBuffer) { + bytes = Buffer.from(data); + } else if (ArrayBuffer.isView(data)) { + bytes = Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + if (!bytes) return data; + + const contentType = headers.get("content-type")?.toLowerCase() ?? ""; + if (!contentType.includes("json") && !contentType.startsWith("text/")) return data; + + const text = bytes.toString("utf8"); + if (contentType.includes("json")) { + try { + return JSON.parse(text); + } catch { + return text; + } + } + return text; + } + /** * Execute an OpenAPI operation */ @@ -165,6 +234,7 @@ export class HttpClient { headers: { ...headers, }, + ...(this.hasBinaryResponse(operation) ? { responseType: "arraybuffer" as const } : {}), }; // first argument is url parameters, second is body parameters @@ -179,7 +249,7 @@ export class HttpClient { }); return { - data: response.data, + data: this.decodeTextData(response.data, responseHeaders) as T, status: response.status, headers: responseHeaders, }; @@ -190,13 +260,9 @@ export class HttpClient { Object.entries(error.response.headers).forEach(([key, value]) => { if (value) headers.append(key, value.toString()); }); + const data = this.decodeTextData(error.response.data, headers); - throw new HttpClientError( - error.response.statusText || "Request failed", - error.response.status, - error.response.data, - headers, - ); + throw new HttpClientError(error.response.statusText || "Request failed", error.response.status, data, headers); } throw error; } diff --git a/src/mcp/__tests__/proxy.test.ts b/src/mcp/__tests__/proxy.test.ts index 6acebc6..6ab5369 100644 --- a/src/mcp/__tests__/proxy.test.ts +++ b/src/mcp/__tests__/proxy.test.ts @@ -1,8 +1,9 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { Headers } from "node-fetch"; +import { Buffer } from "node:buffer"; 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 @@ -96,6 +97,100 @@ describe("MCPProxy", () => { }); }); + it("should return binary images as base64 MCP image content", async () => { + const imageBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + (HttpClient.prototype.executeOperation as ReturnType).mockResolvedValue({ + data: imageBytes, + status: 200, + headers: new Headers({ "content-type": "image/png; charset=binary" }), + }); + (proxy as any).openApiLookup = { + "API-download-file": { + operationId: "download_file", + responses: { "200": { description: "File contents" } }, + method: "get", + path: "/v1/spaces/{space_id}/files/{file_id}", + }, + }; + + const [, callToolHandler] = getHandlers(proxy); + const result = await callToolHandler({ + params: { + name: "API-download-file", + arguments: { space_id: "space-1", file_id: "image-1", width: 800 }, + }, + }); + + expect(result).toEqual({ + content: [{ type: "image", data: imageBytes.toString("base64"), mimeType: "image/png" }], + }); + }); + + it("should return other binary data as a base64 embedded resource", async () => { + const pdfBytes = Buffer.from("%PDF-1.7\n"); + (HttpClient.prototype.executeOperation as ReturnType).mockResolvedValue({ + data: pdfBytes, + status: 200, + headers: new Headers({ "content-type": "application/pdf" }), + }); + (proxy as any).openApiLookup = { + "API-download-file": { + operationId: "download_file", + responses: { "200": { description: "File contents" } }, + method: "get", + path: "/v1/spaces/{space_id}/files/{file_id}", + }, + }; + + const [, callToolHandler] = getHandlers(proxy); + const result = await callToolHandler({ + params: { + name: "API-download-file", + arguments: { space_id: "space-1", file_id: "document-1" }, + }, + }); + + expect(result).toEqual({ + content: [ + { + type: "resource", + resource: { + uri: "anytype://api/download_file?space_id=space-1&file_id=document-1", + blob: pdfBytes.toString("base64"), + mimeType: "application/pdf", + }, + }, + ], + }); + }); + + it("should preserve structured JSON errors from binary operations", async () => { + const errorData = { code: "NOT_FOUND", message: "File not found" }; + const error = Object.assign(new HttpClientError("Not Found", 404, errorData), { + status: 404, + data: errorData, + headers: new Headers({ "content-type": "application/json" }), + }); + (HttpClient.prototype.executeOperation as ReturnType).mockRejectedValue(error); + (proxy as any).openApiLookup = { + "API-download-file": { + operationId: "download_file", + responses: { "200": { description: "File contents" } }, + method: "get", + path: "/v1/spaces/{space_id}/files/{file_id}", + }, + }; + + const [, callToolHandler] = getHandlers(proxy); + const result = await callToolHandler({ + params: { name: "API-download-file", arguments: { space_id: "space-1", file_id: "missing" } }, + }); + + expect(result).toEqual({ + content: [{ type: "text", text: JSON.stringify({ status: "error", ...errorData }) }], + }); + }); + it("should throw error for non-existent operation", async () => { const [, callToolHandler] = getHandlers(proxy); diff --git a/src/mcp/proxy.ts b/src/mcp/proxy.ts index 537ae22..ffde6c9 100644 --- a/src/mcp/proxy.ts +++ b/src/mcp/proxy.ts @@ -3,6 +3,8 @@ 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 { Buffer } from "node:buffer"; +import { URL } from "node:url"; import { OpenAPIV3 } from "openapi-types"; import { HttpClient, HttpClientError } from "../client/http-client"; import { OpenAPIToMCPConverter } from "../openapi/parser"; @@ -89,14 +91,7 @@ export class MCPProxy { const response = await this.httpClient.executeOperation(operation, params); // Convert response to MCP format - return { - content: [ - { - type: "text", // currently this is the only type that seems to be used by mcp server - text: JSON.stringify(response.data), // TODO: pass through the http status code text? - }, - ], - }; + return { content: [this.formatResponse(response.data, response.headers, operation, params)] }; } catch (error) { console.error("Error in tool call", error); if (error instanceof HttpClientError) { @@ -154,6 +149,64 @@ export class MCPProxy { return "binary"; } + private formatResponse( + data: unknown, + headers: Headers, + operation: OpenAPIV3.OperationObject, + params: Record | undefined, + ) { + const contentType = this.getContentType(headers); + const mimeType = headers.get("content-type")?.split(";", 1)[0]?.trim() || "application/octet-stream"; + const binaryData = this.toBuffer(data); + + // Preserve the existing JSON/text response behavior. Some APIs omit a + // Content-Type header, so only treat actual byte containers as binary. + if (contentType === "text" || !binaryData) { + return { + type: "text" as const, + text: JSON.stringify(data), + }; + } + + const base64Data = binaryData.toString("base64"); + if (contentType === "image") { + return { + type: "image" as const, + data: base64Data, + mimeType, + }; + } + + return { + type: "resource" as const, + resource: { + uri: this.buildResourceUri(operation, params), + blob: base64Data, + mimeType, + }, + }; + } + + private toBuffer(data: unknown): Buffer | null { + if (Buffer.isBuffer(data)) return data; + if (data instanceof ArrayBuffer) return Buffer.from(data); + if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + return null; + } + + private buildResourceUri(operation: OpenAPIV3.OperationObject, params: Record | undefined): string { + const operationId = encodeURIComponent(operation.operationId || "response"); + const resourceUri = new URL(`anytype://api/${operationId}`); + + for (const [key, value] of Object.entries(params ?? {})) { + if (value !== undefined && value !== null && typeof value !== "object") { + resourceUri.searchParams.set(key, String(value)); + } + } + + return resourceUri.toString(); + } + private truncateToolName(name: string): string { if (name.length <= 64) { return name;