Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 2 additions & 12 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { VERSION } from "./version.js";
import { analyzePdf } from "./service.js";
import { AnalyzePdfInputShape } from "./types.js";
import { resolveActiveProvider } from "./providers/registry.js";
// Cloud-only modules loaded lazily to avoid pulling in heavy deps in stdio mode.
// import { startHttpServer } from "./transports/http.js";
Expand Down Expand Up @@ -138,17 +138,7 @@ export const createServer = (mode: "stdio" | "http" = "stdio"): McpServer => {
"analyze_pdf",
{
description: mode === "http" ? ANALYZE_PDF_DESCRIPTION_HTTP : ANALYZE_PDF_DESCRIPTION_STDIO,
inputSchema: {
pdf_source: z
.union([z.string(), z.array(z.string().min(1)).min(1)])
.describe(
"PDF source: absolute local file path, URL, cached file URI from a previous response (Google only), or array of cached file URIs from a previous chunked response (Google only)"
),
queries: z
.array(z.string().min(1))
.min(1)
.describe("Array of questions to ask about the PDF"),
},
inputSchema: AnalyzePdfInputShape,
},
async ({ pdf_source, queries }) => {
try {
Expand Down
69 changes: 69 additions & 0 deletions src/transports/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,73 @@ describe("HTTP transport", () => {
const res = await fetch(`${baseUrl}/mcp`, { method: "DELETE" });
expect(res.status).not.toBe(404);
});

// Regression for #42: malformed /analyze bodies were reaching analyzePdf and
// crashing inside validateLocalPath with "Cannot read properties of undefined
// (reading 'trim')". The handler now runs zod up front so callers get a
// descriptive 400 instead.
describe("POST /analyze input validation", () => {
async function postAnalyze(baseUrl: string, body: unknown): Promise<Response> {
return fetch(`${baseUrl}/analyze`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}

it("rejects missing pdf_source with 400 and a zod path", async () => {
const { baseUrl, server } = await startTestServer();
testServer = server;

const res = await postAnalyze(baseUrl, { queries: ["What is this?"] });
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string; details: string[] };
expect(body.error).toBe("Invalid request body");
expect(body.details.join("\n")).toContain("pdf_source");
});

it("rejects non-string, non-array pdf_source with 400", async () => {
const { baseUrl, server } = await startTestServer();
testServer = server;

const res = await postAnalyze(baseUrl, { pdf_source: 123, queries: ["q"] });
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string; details: string[] };
expect(body.details.join("\n")).toContain("pdf_source");
});

it("rejects empty queries array with 400", async () => {
const { baseUrl, server } = await startTestServer();
testServer = server;

const res = await postAnalyze(baseUrl, { pdf_source: "/tmp/x.pdf", queries: [] });
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string; details: string[] };
expect(body.details.join("\n")).toContain("queries");
});

it("rejects empty-string queries with 400", async () => {
const { baseUrl, server } = await startTestServer();
testServer = server;

const res = await postAnalyze(baseUrl, { pdf_source: "/tmp/x.pdf", queries: [""] });
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string; details: string[] };
expect(body.details.join("\n")).toContain("queries");
});

it("rejects non-JSON body with 400", async () => {
const { baseUrl, server } = await startTestServer();
testServer = server;

const res = await fetch(`${baseUrl}/analyze`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not json",
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toContain("JSON");
});
});
});
30 changes: 22 additions & 8 deletions src/transports/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { analyzePdf } from "../service.js";
import { AnalyzePdfInputSchema } from "../types.js";
import { resolveActiveProvider } from "../providers/registry.js";

/**
Expand All @@ -33,16 +34,29 @@ function readBody(req: IncomingMessage): Promise<string> {
* Response body: AnalyzePdfResponse JSON
*/
async function handleAnalyze(req: IncomingMessage, res: ServerResponse): Promise<void> {
let body: unknown;
try {
body = JSON.parse(await readBody(req));
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Request body must be valid JSON" }));
return;
}

const parsed = AnalyzePdfInputSchema.safeParse(body);
if (!parsed.success) {
const issues = parsed.error.issues.map((issue) => {
const pathStr = issue.path.length > 0 ? issue.path.join(".") : "(root)";
return `${pathStr}: ${issue.message}`;
});
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid request body", details: issues }));
return;
}

try {
const body = JSON.parse(await readBody(req));
const { pdf_source, queries } = body;
if (!pdf_source || !queries || !Array.isArray(queries) || queries.length === 0) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Required: pdf_source (string) and queries (string[])" }));
return;
}
const { provider, apiKey, modelId } = await resolveActiveProvider();
const result = await analyzePdf(provider, apiKey, modelId, { pdf_source, queries });
const result = await analyzePdf(provider, apiKey, modelId, parsed.data);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(result));
} catch (error) {
Expand Down
11 changes: 7 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { z } from "zod";

/** Schema for the analyze_pdf tool input */
export const AnalyzePdfInputSchema = z.object({
/** Field schemas for the analyze_pdf tool input. Shared between MCP and HTTP. */
export const AnalyzePdfInputShape = {
pdf_source: z
.union([z.string(), z.array(z.string().min(1)).min(1)])
.describe(
"PDF source: file path, URL, single Gemini file URI, or array of Gemini file URIs from a previous chunked response"
"PDF source: absolute local file path, URL, cached file URI from a previous response (Google only), or array of cached file URIs from a previous chunked response (Google only)"
),
queries: z.array(z.string().min(1)).min(1).describe("Array of questions to ask about the PDF"),
});
};

/** Schema for the analyze_pdf tool input */
export const AnalyzePdfInputSchema = z.object(AnalyzePdfInputShape);

/** Input type for the analyze_pdf tool */
export type AnalyzePdfInput = z.infer<typeof AnalyzePdfInputSchema>;
Expand Down
Loading