diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d54c2b8..b6233aa 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "Official Perplexity AI plugin providing real-time web search, reasoning, and research capabilities", - "version": "0.9.0" + "version": "1.0.0" }, "plugins": [ { "name": "perplexity", "source": "./", "description": "Real-time web search, reasoning, and research through Perplexity's API", - "version": "0.9.0", + "version": "1.0.0", "author": { "name": "Perplexity AI", "email": "api@perplexity.ai" diff --git a/README.md b/README.md index 5e2057d..3e9fd69 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@   [![npm version](https://img.shields.io/npm/v/%40perplexity-ai%2Fmcp-server?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/@perplexity-ai/mcp-server) -The official MCP server implementation for the Perplexity API Platform, providing AI assistants with real-time web search, reasoning, and research capabilities through Sonar models and the Search API. +The official MCP server implementation for the Perplexity API Platform, providing AI assistants with real-time web search, reasoning, and research capabilities through the [Agent API](https://docs.perplexity.ai/docs/agent-api/quickstart) and the Search API. ## Available Tools @@ -16,18 +16,16 @@ The official MCP server implementation for the Perplexity API Platform, providin Direct web search using the Perplexity Search API. Returns ranked search results with metadata, perfect for finding current information. ### **perplexity_ask** -General-purpose conversational AI with real-time web search using the `sonar-pro` model. Great for quick questions and everyday searches. +General-purpose conversational AI with real-time web search, backed by the Agent API `fast` preset. Great for quick questions and everyday searches. ### **perplexity_research** -Deep, comprehensive research using the `sonar-deep-research` model. Ideal for thorough analysis and detailed reports. +Deep, comprehensive research backed by the Agent API `high` preset. Ideal for thorough analysis and detailed reports. Runs can take minutes; the server streams the run and reports progress to clients that request it. ### **perplexity_reason** -Advanced reasoning and problem-solving using the `sonar-reasoning-pro` model. Perfect for complex analytical tasks. +Advanced reasoning and problem-solving backed by the Agent API `medium` preset. Perfect for complex analytical tasks. -> [!TIP] -> Available as an optional parameter for **perplexity_reason** and **perplexity_research**: `strip_thinking` -> -> Set to `true` to remove `...` tags from the response, saving context tokens. Default: `false` +> [!NOTE] +> Presets are managed configurations (model, search setup, step budget) that Perplexity keeps tuned over time; see the [presets guide](https://docs.perplexity.ai/docs/agent-api/presets). Earlier versions of this server called the legacy `sonar-pro`, `sonar-reasoning-pro`, and `sonar-deep-research` models and accepted `strip_thinking` / `reasoning_effort` parameters. Those parameters are no longer part of the tool schemas and are ignored if sent; the Agent API produces no `` tags. ## Configuration diff --git a/package-lock.json b/package-lock.json index c5096c6..fb03157 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@perplexity-ai/mcp-server", - "version": "0.9.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@perplexity-ai/mcp-server", - "version": "0.9.0", + "version": "1.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.21.1", + "@modelcontextprotocol/sdk": "^1.29.0", "cors": "^2.8.5", "express": "^4.21.2", "undici": "^6.20.0", @@ -575,9 +575,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.27.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", - "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", diff --git a/package.json b/package.json index b5bf6cc..3cb6761 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@perplexity-ai/mcp-server", - "version": "0.9.0", + "version": "1.0.0", "mcpName": "ai.perplexity/mcp-server", "description": "Real-time web search, reasoning, and research through Perplexity's API", "keywords": [ @@ -49,7 +49,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.21.1", + "@modelcontextprotocol/sdk": "^1.29.0", "cors": "^2.8.5", "express": "^4.21.2", "undici": "^6.20.0", diff --git a/server.json b/server.json index 0ebc5cd..2f456ac 100644 --- a/server.json +++ b/server.json @@ -3,12 +3,12 @@ "name": "ai.perplexity/mcp-server", "title": "Perplexity API Platform", "description": "Real-time web search, reasoning, and research through Perplexity's API", - "version": "0.9.0", + "version": "1.0.0", "packages": [ { "registryType": "npm", "identifier": "@perplexity-ai/mcp-server", - "version": "0.9.0", + "version": "1.0.0", "transport": { "type": "stdio" } diff --git a/src/index.test.ts b/src/index.test.ts index 21897b8..b616fd2 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,15 +1,72 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { formatSearchResults, performChatCompletion, performSearch } from "./server.js"; +import { + formatSearchResults, + performAgentResponse, + performSearch, +} from "./server.js"; +import type { AgentOutputItem, AgentProgressUpdate } from "./types.js"; + +const AGENT_URL = "https://api.perplexity.ai/v1/agent"; +const SEARCH_URL = "https://api.perplexity.ai/search"; +const TEST_MESSAGES = [{ role: "user", content: "test question" }]; + +function encodeSse(events: Array>): Uint8Array { + const payload = + events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") + + "data: [DONE]\n\n"; + return new TextEncoder().encode(payload); +} + +function sseResponse(events: Array>): Response { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encodeSse(events)); + controller.close(); + }, + }); + return { ok: true, body: stream } as unknown as Response; +} + +function completedEvent( + text: string, + extraOutput: AgentOutputItem[] = [] +): Record { + return { + type: "response.completed", + response: { + id: "resp_test", + status: "completed", + model: "test-model", + output: [ + ...extraOutput, + { + type: "message", + id: "msg_test", + status: "completed", + role: "assistant", + content: [{ type: "output_text", text, annotations: [] }], + }, + ], + }, + }; +} describe("Perplexity MCP Server", () => { let originalFetch: typeof global.fetch; + let originalTimeoutMs: string | undefined; beforeEach(() => { originalFetch = global.fetch; + originalTimeoutMs = process.env.PERPLEXITY_TIMEOUT_MS; }); afterEach(() => { global.fetch = originalFetch; + if (originalTimeoutMs === undefined) { + delete process.env.PERPLEXITY_TIMEOUT_MS; + } else { + process.env.PERPLEXITY_TIMEOUT_MS = originalTimeoutMs; + } vi.restoreAllMocks(); }); @@ -54,29 +111,17 @@ describe("Perplexity MCP Server", () => { }); }); - describe("performChatCompletion", () => { - it("should successfully complete chat request", async () => { - const mockResponse = { - choices: [ - { - message: { - content: "This is a test response", - }, - }, - ], - }; - - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => mockResponse, - } as Response); + describe("performAgentResponse", () => { + it("should send a streaming preset request and return the answer text", async () => { + global.fetch = vi + .fn() + .mockResolvedValue(sseResponse([completedEvent("This is a test response")])); - const messages = [{ role: "user", content: "test question" }]; - const result = await performChatCompletion(messages, "sonar-pro"); + const result = await performAgentResponse(TEST_MESSAGES, "fast"); expect(result).toBe("This is a test response"); expect(global.fetch).toHaveBeenCalledWith( - "https://api.perplexity.ai/chat/completions", + AGENT_URL, expect.objectContaining({ method: "POST", headers: expect.objectContaining({ @@ -85,40 +130,176 @@ describe("Perplexity MCP Server", () => { "X-Source": "pplx-mcp-server", }), body: JSON.stringify({ - model: "sonar-pro", - messages, + preset: "fast", + input: [{ type: "message", role: "user", content: "test question" }], + stream: true, }), }) ); }); - it("should append citations when present", async () => { - const mockResponse = { - choices: [ - { - message: { - content: "Response with citations", - }, - }, - ], - citations: [ - "https://example.com/source1", - "https://example.com/source2", + it("should append id-keyed citations from search results", async () => { + const searchResults: AgentOutputItem = { + type: "search_results", + results: [ + { id: 1, url: "https://example.com/first" }, + { id: 2, url: "https://example.com/second" }, + { id: 3, url: "https://example.com/third" }, ], }; + global.fetch = vi.fn().mockResolvedValue( + sseResponse([ + completedEvent("Answer citing sources[3][1].", [searchResults]), + ]) + ); - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => mockResponse, - } as Response); + const result = await performAgentResponse(TEST_MESSAGES, "fast"); + + expect(result).toContain("Answer citing sources[3][1]."); + expect(result).toContain( + "\n\nCitations:\n[1] https://example.com/first\n[2] https://example.com/second\n[3] https://example.com/third\n" + ); + }); + + it("should map search options onto the web_search tool", async () => { + global.fetch = vi + .fn() + .mockResolvedValue(sseResponse([completedEvent("ok")])); + + await performAgentResponse(TEST_MESSAGES, "fast", undefined, { + search_recency_filter: "week", + search_domain_filter: ["wikipedia.org", "-reddit.com"], + search_context_size: "high", + }); + + const body = JSON.parse( + (global.fetch as ReturnType).mock.calls[0][1].body as string + ); + expect(body.tools).toEqual([ + { + type: "web_search", + filters: { + search_recency_filter: "week", + search_domain_filter: ["wikipedia.org", "-reddit.com"], + }, + search_context_size: "high", + }, + ]); + }); + + it("should omit the tools override when no search options are set", async () => { + global.fetch = vi + .fn() + .mockResolvedValue(sseResponse([completedEvent("ok")])); + + await performAgentResponse(TEST_MESSAGES, "medium"); + + const body = JSON.parse( + (global.fetch as ReturnType).mock.calls[0][1].body as string + ); + expect(body.tools).toBeUndefined(); + expect(body.preset).toBe("medium"); + }); + + it("should emit progress updates from reasoning events", async () => { + global.fetch = vi.fn().mockResolvedValue( + sseResponse([ + { type: "response.created", response: { id: "resp_test" } }, + { + type: "response.reasoning.search_queries", + queries: ["battery production", "solid state timeline"], + }, + { + type: "response.reasoning.search_results", + results: [{ url: "https://example.com" }, { url: "https://example.org" }], + }, + { + type: "response.output_item.added", + item: { type: "message" }, + }, + completedEvent("done"), + ]) + ); + + const updates: AgentProgressUpdate[] = []; + const result = await performAgentResponse( + TEST_MESSAGES, + "medium", + undefined, + undefined, + { onProgress: (update) => updates.push(update) } + ); + + expect(result).toBe("done"); + expect(updates.map((u) => u.message)).toEqual([ + "Searching: battery production | solid state timeline", + "Reading 2 search results", + "Writing answer", + ]); + }); + + it("should cancel the server-side run when the request is aborted", async () => { + const abortController = new AbortController(); + const cancelCalls: string[] = []; + + global.fetch = vi.fn().mockImplementation((url, options) => { + if (String(url).includes("/cancel")) { + cancelCalls.push(String(url)); + return Promise.resolve({ + ok: true, + json: async () => ({ response_id: "resp_cancel", status: "cancelling" }), + } as unknown as Response); + } + const signal = options?.signal as AbortSignal | undefined; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encodeSse([{ type: "response.created", response: { id: "resp_cancel" } }]) + ); + signal?.addEventListener("abort", () => { + controller.error( + new DOMException("The operation was aborted.", "AbortError") + ); + }); + }, + }); + return Promise.resolve({ ok: true, body: stream } as unknown as Response); + }); + + const pending = performAgentResponse(TEST_MESSAGES, "medium", undefined, undefined, { + signal: abortController.signal, + }); + setTimeout(() => abortController.abort(), 20); - const messages = [{ role: "user", content: "test" }]; - const result = await performChatCompletion(messages); + await expect(pending).rejects.toThrow("Request cancelled"); + await vi.waitFor(() => { + expect(cancelCalls).toEqual([ + "https://api.perplexity.ai/v1/agent/resp_cancel/cancel", + ]); + }); + }); - expect(result).toContain("Response with citations"); - expect(result).toContain("\n\nCitations:\n"); - expect(result).toContain("[1] https://example.com/source1"); - expect(result).toContain("[2] https://example.com/source2"); + it("should surface stream failure events as errors", async () => { + global.fetch = vi.fn().mockResolvedValue( + sseResponse([ + { type: "response.created", response: { id: "resp_test" } }, + { type: "response.failed", error: { message: "model unavailable" } }, + ]) + ); + + await expect(performAgentResponse(TEST_MESSAGES, "fast")).rejects.toThrow( + "Perplexity API error: model unavailable" + ); + }); + + it("should error when the stream ends without a completed response", async () => { + global.fetch = vi.fn().mockResolvedValue( + sseResponse([{ type: "response.created", response: { id: "resp_test" } }]) + ); + + await expect(performAgentResponse(TEST_MESSAGES, "fast")).rejects.toThrow( + "Agent stream ended without a completed response" + ); }); it("should handle API errors", async () => { @@ -129,9 +310,7 @@ describe("Perplexity MCP Server", () => { text: async () => "Invalid API key", } as Response); - const messages = [{ role: "user", content: "test" }]; - - await expect(performChatCompletion(messages)).rejects.toThrow( + await expect(performAgentResponse(TEST_MESSAGES, "fast")).rejects.toThrow( "Perplexity API error: 401 Unauthorized" ); }); @@ -150,17 +329,12 @@ describe("Perplexity MCP Server", () => { } setTimeout(() => { - resolve({ - ok: true, - json: async () => ({ choices: [{ message: { content: "late" } }] }), - } as Response); + resolve(sseResponse([completedEvent("late")])); }, 200); }); }); - const messages = [{ role: "user", content: "test" }]; - - await expect(performChatCompletion(messages)).rejects.toThrow( + await expect(performAgentResponse(TEST_MESSAGES, "fast")).rejects.toThrow( "Request timeout" ); }); @@ -168,12 +342,227 @@ describe("Perplexity MCP Server", () => { it("should handle network errors", async () => { global.fetch = vi.fn().mockRejectedValue(new Error("Network failure")); - const messages = [{ role: "user", content: "test" }]; - - await expect(performChatCompletion(messages)).rejects.toThrow( + await expect(performAgentResponse(TEST_MESSAGES, "fast")).rejects.toThrow( "Network error while calling Perplexity API" ); }); + + it("should time out a stalled stream and cancel the server-side run", async () => { + process.env.PERPLEXITY_TIMEOUT_MS = "100"; + const cancelCalls: string[] = []; + + global.fetch = vi.fn().mockImplementation((url, options) => { + if (String(url).includes("/cancel")) { + cancelCalls.push(String(url)); + return Promise.resolve({ + ok: true, + json: async () => ({ response_id: "resp_stall", status: "cancelling" }), + } as unknown as Response); + } + const signal = options?.signal as AbortSignal | undefined; + // Headers arrive immediately, then the stream stalls forever. + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encodeSse([{ type: "response.created", response: { id: "resp_stall" } }]) + ); + signal?.addEventListener("abort", () => { + controller.error( + new DOMException("The operation was aborted.", "AbortError") + ); + }); + }, + }); + return Promise.resolve({ ok: true, body: stream } as unknown as Response); + }); + + await expect(performAgentResponse(TEST_MESSAGES, "medium")).rejects.toThrow( + "Request timeout" + ); + await vi.waitFor(() => { + expect(cancelCalls).toEqual([ + "https://api.perplexity.ai/v1/agent/resp_stall/cancel", + ]); + }); + }); + + it("should return the answer when the stream stays open after completion", async () => { + process.env.PERPLEXITY_TIMEOUT_MS = "100"; + const cancelCalls: string[] = []; + + global.fetch = vi.fn().mockImplementation((url) => { + if (String(url).includes("/cancel")) { + cancelCalls.push(String(url)); + return Promise.resolve({ ok: true, json: async () => ({}) } as unknown as Response); + } + // Terminal event arrives immediately, but the connection never closes + // (e.g. an intermediary holding the socket open). + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encodeSse([completedEvent("late close")])); + }, + }); + return Promise.resolve({ ok: true, body: stream } as unknown as Response); + }); + + const result = await performAgentResponse(TEST_MESSAGES, "fast"); + + expect(result).toBe("late close"); + expect(cancelCalls).toEqual([]); + }); + + it("should surface a failure event even if the stream never closes", async () => { + process.env.PERPLEXITY_TIMEOUT_MS = "100"; + + global.fetch = vi.fn().mockImplementation(() => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encodeSse([ + { type: "response.created", response: { id: "resp_fail" } }, + { type: "response.failed", error: { message: "model unavailable" } }, + ]) + ); + }, + }); + return Promise.resolve({ ok: true, body: stream } as unknown as Response); + }); + + await expect(performAgentResponse(TEST_MESSAGES, "fast")).rejects.toThrow( + "Perplexity API error: model unavailable" + ); + }); + + it("should wrap mid-stream connection failures as network errors", async () => { + global.fetch = vi.fn().mockImplementation(() => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encodeSse([{ type: "response.created", response: { id: "resp_drop" } }]) + ); + controller.error(new TypeError("terminated")); + }, + }); + return Promise.resolve({ ok: true, body: stream } as unknown as Response); + }); + + await expect(performAgentResponse(TEST_MESSAGES, "fast")).rejects.toThrow( + "Network error while streaming from Perplexity API" + ); + }); + + it("should tolerate null fields anywhere in the response", async () => { + const searchResults: AgentOutputItem = { + type: "search_results", + results: [ + { id: null, url: null }, + { id: 2, url: "https://example.com/real", title: null, date: null }, + ], + }; + const completed = completedEvent("Answer.", [searchResults]) as any; + completed.response.id = null; + completed.response.status = null; + completed.response.model = null; + global.fetch = vi.fn().mockResolvedValue(sseResponse([completed])); + + const result = await performAgentResponse(TEST_MESSAGES, "fast"); + + expect(result).toContain("Answer."); + expect(result).toContain("[2] https://example.com/real"); + }); + + it("should wrap schema errors from a malformed completed response", async () => { + global.fetch = vi.fn().mockResolvedValue( + sseResponse([ + { type: "response.completed", response: { id: "resp_bad", output: "not-an-array" } }, + ]) + ); + + await expect(performAgentResponse(TEST_MESSAGES, "fast")).rejects.toThrow( + "Invalid response from Perplexity Agent API" + ); + }); + + it("should honor PERPLEXITY_BASE_URL on the agent path", async () => { + vi.resetModules(); + process.env.PERPLEXITY_BASE_URL = "https://proxy.example.com"; + try { + const serverModule = await import("./server.js"); + global.fetch = vi + .fn() + .mockResolvedValue(sseResponse([completedEvent("ok")])); + + await serverModule.performAgentResponse(TEST_MESSAGES, "fast"); + + expect(global.fetch).toHaveBeenCalledWith( + "https://proxy.example.com/v1/agent", + expect.anything() + ); + } finally { + delete process.env.PERPLEXITY_BASE_URL; + vi.resetModules(); + } + }); + + it("should skip malformed stream chunks and still complete", async () => { + const payload = + "data: this-is-not-json\n\n" + + `data: ${JSON.stringify(completedEvent("recovered"))}\n\n` + + "data: [DONE]\n\n"; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(payload)); + controller.close(); + }, + }); + global.fetch = vi + .fn() + .mockResolvedValue({ ok: true, body: stream } as unknown as Response); + + const result = await performAgentResponse(TEST_MESSAGES, "fast"); + expect(result).toBe("recovered"); + }); + + it("should decode multi-byte characters split across stream chunks", async () => { + const payload = encodeSse([completedEvent("Response with émojis 🎉 and unicode ñ")]); + // Split inside the 4-byte emoji to exercise the stateful decoder. + const splitAt = payload.findIndex((_, i) => payload[i] === 0xf0) + 2; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(payload.slice(0, splitAt)); + controller.enqueue(payload.slice(splitAt)); + controller.close(); + }, + }); + global.fetch = vi + .fn() + .mockResolvedValue({ ok: true, body: stream } as unknown as Response); + + const result = await performAgentResponse(TEST_MESSAGES, "fast"); + + expect(result).toBe("Response with émojis 🎉 and unicode ñ"); + }); + + it("should pass through multi-turn conversations", async () => { + global.fetch = vi + .fn() + .mockResolvedValue(sseResponse([completedEvent("ok")])); + + const conversation = [ + { role: "system", content: "Be terse." }, + { role: "user", content: "First question" }, + { role: "assistant", content: "First answer" }, + { role: "user", content: "Follow-up" }, + ]; + await performAgentResponse(conversation, "fast"); + + const body = JSON.parse( + (global.fetch as ReturnType).mock.calls[0][1].body as string + ); + expect(body.input).toEqual( + conversation.map((message) => ({ type: "message", ...message })) + ); + }); }); describe("performSearch", () => { @@ -198,7 +587,7 @@ describe("Perplexity MCP Server", () => { expect(result).toContain("Found 1 search results"); expect(result).toContain("Search Result"); expect(global.fetch).toHaveBeenCalledWith( - "https://api.perplexity.ai/search", + SEARCH_URL, expect.objectContaining({ method: "POST", headers: expect.objectContaining({ @@ -225,7 +614,7 @@ describe("Perplexity MCP Server", () => { await performSearch("test", 10, 1024, "US"); expect(global.fetch).toHaveBeenCalledWith( - "https://api.perplexity.ai/search", + SEARCH_URL, expect.objectContaining({ body: JSON.stringify({ query: "test", @@ -250,171 +639,14 @@ describe("Perplexity MCP Server", () => { ); }); - it("should handle search timeout errors", async () => { - process.env.PERPLEXITY_TIMEOUT_MS = "100"; - - global.fetch = vi.fn().mockImplementation((_url, options) => { - return new Promise((resolve, reject) => { - const signal = options?.signal as AbortSignal; - - if (signal) { - signal.addEventListener("abort", () => { - reject(new DOMException("The operation was aborted.", "AbortError")); - }); - } - - setTimeout(() => { - resolve({ - ok: true, - json: async () => ({ results: [] }), - } as Response); - }, 200); - }); - }); - - await expect(performSearch("test")).rejects.toThrow( - "Request timeout" - ); - }); - - it("should handle search network errors", async () => { - global.fetch = vi.fn().mockRejectedValue(new Error("Network failure")); - - await expect(performSearch("test")).rejects.toThrow( - "Network error while calling Perplexity API" - ); - }); - }); - - describe("API Response Validation", () => { - it("should handle empty choices array", async () => { + it("should wrap malformed search responses", async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ choices: [] }), + json: async () => ({ results: "not-an-array" }), } as Response); - const messages = [{ role: "user", content: "test" }]; - - await expect(performChatCompletion(messages)).rejects.toThrow( - "missing or empty choices array" - ); - }); - - it("should handle missing message content", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ choices: [{ message: null }] }), - } as Response); - - const messages = [{ role: "user", content: "test" }]; - - await expect(performChatCompletion(messages)).rejects.toThrow( - "missing message content" - ); - }); - - it("should handle missing choices property", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({}), - } as Response); - - const messages = [{ role: "user", content: "test" }]; - - await expect(performChatCompletion(messages)).rejects.toThrow( - "missing or empty choices array" - ); - }); - - it("should handle malformed message object", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ choices: [{ message: { content: 123 } }] }), - } as Response); - - const messages = [{ role: "user", content: "test" }]; - - await expect(performChatCompletion(messages)).rejects.toThrow( - "missing message content" - ); - }); - - it("should handle null choices", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ choices: null }), - } as Response); - - const messages = [{ role: "user", content: "test" }]; - - await expect(performChatCompletion(messages)).rejects.toThrow( - "missing or empty choices array" - ); - }); - - it("should handle undefined message in choice", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ choices: [{}] }), - } as Response); - - const messages = [{ role: "user", content: "test" }]; - - await expect(performChatCompletion(messages)).rejects.toThrow( - "missing message content" - ); - }); - - it("should handle empty citations array gracefully", async () => { - const mockResponse = { - choices: [{ message: { content: "Response" } }], - citations: [], - }; - - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => mockResponse, - } as Response); - - const messages = [{ role: "user", content: "test" }]; - const result = await performChatCompletion(messages); - - expect(result).toBe("Response"); - expect(result).not.toContain("Citations:"); - }); - - it("should handle non-array citations", async () => { - const mockResponse = { - choices: [{ message: { content: "Response" } }], - citations: "not-an-array", - }; - - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => mockResponse, - } as Response); - - const messages = [{ role: "user", content: "test" }]; - - await expect(performChatCompletion(messages)).rejects.toThrow( - "Failed to parse JSON response" - ); - }); - }); - - describe("Edge Cases", () => { - it("should handle JSON parse errors gracefully", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => { - throw new Error("Invalid JSON"); - }, - } as unknown as Response); - - const messages = [{ role: "user", content: "test" }]; - - await expect(performChatCompletion(messages)).rejects.toThrow( - "Failed to parse JSON response" + await expect(performSearch("test")).rejects.toThrow( + "Failed to parse JSON response from Perplexity Search API" ); }); @@ -428,359 +660,28 @@ describe("Perplexity MCP Server", () => { }, } as unknown as Response); - const messages = [{ role: "user", content: "test" }]; - - await expect(performChatCompletion(messages)).rejects.toThrow( + await expect(performSearch("test")).rejects.toThrow( "Unable to parse error response" ); }); + }); - it("should handle special characters in messages", async () => { - const mockResponse = { - choices: [{ message: { content: "Response with émojis 🎉 and unicode ñ" } }], - }; - - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => mockResponse, - } as Response); - - const messages = [{ role: "user", content: "test with émojis 🎉" }]; - const result = await performChatCompletion(messages); - - expect(result).toContain("émojis 🎉"); - expect(result).toContain("unicode ñ"); - }); - - it("should handle very long content strings", async () => { - const longContent = "x".repeat(100000); - const mockResponse = { - choices: [{ message: { content: longContent } }], - }; - - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => mockResponse, - } as Response); - - const messages = [{ role: "user", content: "test" }]; - const result = await performChatCompletion(messages); - - expect(result).toBe(longContent); - expect(result.length).toBe(100000); - }); - - it("should handle multiple models correctly", async () => { - const models = ["sonar-pro", "sonar-deep-research", "sonar-reasoning-pro"]; - - for (const model of models) { - if (model === "sonar-deep-research") { - // sonar-deep-research uses streaming, so provide an SSE mock - const sseData = [ - `data: ${JSON.stringify({ choices: [{ delta: { content: "Response " } }] })}\n\n`, - `data: ${JSON.stringify({ choices: [{ delta: { content: `from ${model}` } }] })}\n\n`, - `data: [DONE]\n\n`, - ].join(""); - - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(sseData)); - controller.close(); - }, - }); - - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - body: stream, - } as unknown as Response); - } else { - const mockResponse = { - choices: [{ message: { content: `Response from ${model}` } }], - }; - - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => mockResponse, - } as Response); - } - - const messages = [{ role: "user", content: "test" }]; - const result = await performChatCompletion(messages, model); - - expect(result).toContain(model); - expect(global.fetch).toHaveBeenCalledWith( - "https://api.perplexity.ai/chat/completions", - expect.objectContaining({ - body: expect.stringContaining(`"model":"${model}"`), - }) - ); - } - }); - - it("should handle search with boundary values", async () => { - const mockResponse = { results: [] }; - - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => mockResponse, - } as Response); - - // Test max values - await performSearch("test", 20, 2048); - expect(global.fetch).toHaveBeenCalledWith( - "https://api.perplexity.ai/search", - expect.objectContaining({ - body: expect.stringContaining('"max_results":20'), - }) - ); - - // Test min values - await performSearch("test", 1, 256); - expect(global.fetch).toHaveBeenCalledWith( - "https://api.perplexity.ai/search", - expect.objectContaining({ - body: expect.stringContaining('"max_results":1'), - }) - ); - }); - - it("should handle formatSearchResults with missing optional fields", async () => { + describe("formatSearchResults Edge Cases", () => { + it("should render partial results without leaking 'undefined'", () => { const mockData = { results: [ { title: "Test", url: "https://example.com" }, { title: "Test 2", url: "https://example.com/2", snippet: "snippet only" }, - { title: "Test 3", url: "https://example.com/3", date: "2025-01-01" }, + { title: null, url: "https://example.com/3", date: "2025-01-01" }, ], - }; + } as any; const formatted = formatSearchResults(mockData); - expect(formatted).toContain("Test"); - expect(formatted).toContain("Test 2"); + expect(formatted).toContain("Found 3 search results"); expect(formatted).toContain("snippet only"); expect(formatted).toContain("Date: 2025-01-01"); expect(formatted).not.toContain("undefined"); }); - - it("should handle concurrent requests correctly", async () => { - let callCount = 0; - global.fetch = vi.fn().mockImplementation(async () => { - const currentCall = ++callCount; - await new Promise((resolve) => setTimeout(resolve, 10)); - return { - ok: true, - json: async () => ({ - choices: [{ message: { content: `Response ${currentCall}` } }] - }), - } as Response; - }); - - const messages = [{ role: "user", content: "test" }]; - const promises = [ - performChatCompletion(messages), - performChatCompletion(messages), - performChatCompletion(messages), - ]; - - const results = await Promise.all(promises); - - expect(results).toHaveLength(3); - expect(global.fetch).toHaveBeenCalledTimes(3); - // Results should all be present (may not be unique due to timing) - expect(results.every(r => r.startsWith("Response"))).toBe(true); - }); - - it("should respect timeout on each call independently", async () => { - // First call with long timeout - process.env.PERPLEXITY_TIMEOUT_MS = "1000"; - - global.fetch = vi.fn().mockImplementation((_url, options) => { - return new Promise((resolve) => { - const signal = options?.signal as AbortSignal; - setTimeout(() => { - if (!signal?.aborted) { - resolve({ - ok: true, - json: async () => ({ choices: [{ message: { content: "fast" } }] }), - } as Response); - } - }, 50); - }); - }); - - const messages = [{ role: "user", content: "test" }]; - const result1 = await performChatCompletion(messages); - expect(result1).toBe("fast"); - - // Second call with short timeout - process.env.PERPLEXITY_TIMEOUT_MS = "10"; - - global.fetch = vi.fn().mockImplementation((_url, options) => { - return new Promise((resolve, reject) => { - const signal = options?.signal as AbortSignal; - - if (signal) { - signal.addEventListener("abort", () => { - reject(new DOMException("The operation was aborted.", "AbortError")); - }); - } - - setTimeout(() => { - resolve({ - ok: true, - json: async () => ({ choices: [{ message: { content: "slow" } }] }), - } as Response); - }, 100); - }); - }); - - await expect(performChatCompletion(messages)).rejects.toThrow("timeout"); - }); - }); - - describe("formatSearchResults Edge Cases", () => { - it("should handle results with null/undefined values", () => { - const mockData = { - results: [ - { title: null, url: "https://example.com", snippet: undefined }, - { title: "Valid", url: null, snippet: "snippet", date: undefined }, - ], - } as any; - - const formatted = formatSearchResults(mockData); - - expect(formatted).toContain("null"); - expect(formatted).toContain("Valid"); - expect(formatted).not.toContain("undefined"); - }); - - it("should handle empty strings in result fields", () => { - const mockData = { - results: [{ title: "", url: "", snippet: "", date: "" }], - }; - - const formatted = formatSearchResults(mockData); - - expect(formatted).toContain("Found 1 search results"); - }); - - it("should handle results with extra unexpected fields", () => { - const mockData = { - results: [ - { - title: "Test", - url: "https://example.com", - unexpectedField: "should be ignored", - anotherField: 12345, - }, - ], - }; - - const formatted = formatSearchResults(mockData); - - expect(formatted).toContain("Test"); - expect(formatted).not.toContain("unexpectedField"); - expect(formatted).not.toContain("12345"); - }); - }); - - describe("strip_thinking parameter", () => { - it("should strip thinking tokens when true and keep them when false", async () => { - const mockResponse = { - choices: [ - { - message: { - content: "This is my reasoning process\n\nThe answer is 4.", - }, - }, - ], - }; - - // Test with stripThinking = true - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => mockResponse, - } as Response); - - const messages = [{ role: "user", content: "What is 2+2?" }]; - const resultStripped = await performChatCompletion(messages, "sonar-reasoning-pro", true); - - expect(resultStripped).not.toContain(""); - expect(resultStripped).not.toContain(""); - expect(resultStripped).not.toContain("This is my reasoning process"); - expect(resultStripped).toContain("The answer is 4."); - - // Test with stripThinking = false - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => mockResponse, - } as Response); - - const resultKept = await performChatCompletion(messages, "sonar-reasoning-pro", false); - - expect(resultKept).toContain("This is my reasoning process"); - expect(resultKept).toContain("The answer is 4."); - }); - }); - - describe("Proxy Support", () => { - const originalEnv = process.env; - - beforeEach(() => { - // Reset environment variables - process.env = { ...originalEnv }; - delete process.env.PERPLEXITY_PROXY; - delete process.env.HTTPS_PROXY; - delete process.env.HTTP_PROXY; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - it("should use native fetch when no proxy is configured", async () => { - const mockResponse = { - choices: [{ message: { content: "Test response" } }], - }; - - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => mockResponse, - } as Response); - - const messages = [{ role: "user", content: "test" }]; - await performChatCompletion(messages); - - // Verify native fetch was called (not undici) - expect(global.fetch).toHaveBeenCalled(); - }); - - it("should read PERPLEXITY_PROXY environment variable", () => { - process.env.PERPLEXITY_PROXY = "http://proxy.example.com:8080"; - expect(process.env.PERPLEXITY_PROXY).toBe("http://proxy.example.com:8080"); - }); - - it("should prioritize PERPLEXITY_PROXY over HTTPS_PROXY", () => { - process.env.PERPLEXITY_PROXY = "http://perplexity-proxy.example.com:8080"; - process.env.HTTPS_PROXY = "http://https-proxy.example.com:8080"; - - // PERPLEXITY_PROXY should take precedence - expect(process.env.PERPLEXITY_PROXY).toBe("http://perplexity-proxy.example.com:8080"); - }); - - it("should fall back to HTTPS_PROXY when PERPLEXITY_PROXY is not set", () => { - delete process.env.PERPLEXITY_PROXY; - process.env.HTTPS_PROXY = "http://https-proxy.example.com:8080"; - - expect(process.env.HTTPS_PROXY).toBe("http://https-proxy.example.com:8080"); - }); - - it("should fall back to HTTP_PROXY when others are not set", () => { - delete process.env.PERPLEXITY_PROXY; - delete process.env.HTTPS_PROXY; - process.env.HTTP_PROXY = "http://http-proxy.example.com:8080"; - - expect(process.env.HTTP_PROXY).toBe("http://http-proxy.example.com:8080"); - }); }); }); diff --git a/src/server.test.ts b/src/server.test.ts index d1efb3f..0d5eac3 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,59 +1,207 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { stripThinkingTokens, getProxyUrl, proxyAwareFetch, validateMessages } from "./server.js"; +import { + extractAgentText, + formatAgentResponseText, + getProxyUrl, + proxyAwareFetch, + validateMessages, +} from "./server.js"; +import type { AgentResponse } from "./types.js"; + +function agentResponse(output: AgentResponse["output"]): AgentResponse { + return { id: "resp_test", status: "completed", output }; +} + +function messageItem(text: string): AgentResponse["output"][number] { + return { + type: "message", + role: "assistant", + content: [{ type: "output_text", text }], + }; +} describe("Server Utility Functions", () => { - describe("stripThinkingTokens", () => { - it("should remove thinking tokens from content", () => { - const content = "Hello This is internal thinking world!"; - const result = stripThinkingTokens(content); - expect(result).toBe("Hello world!"); - }); - - it("should handle multiple thinking tokens", () => { - const content = "First thought Hello Second thought world!"; - const result = stripThinkingTokens(content); - expect(result).toBe("Hello world!"); - }); - - it("should handle multiline thinking tokens", () => { - const content = "Start \nMultiple\nLines\nOf\nThinking\n End"; - const result = stripThinkingTokens(content); - expect(result).toBe("Start End"); - }); - - it("should handle content without thinking tokens", () => { - const content = "No thinking tokens here!"; - const result = stripThinkingTokens(content); - expect(result).toBe("No thinking tokens here!"); - }); - - it("should handle empty content", () => { - const result = stripThinkingTokens(""); - expect(result).toBe(""); + describe("extractAgentText", () => { + it("should join output_text parts across message items", () => { + const response = agentResponse([ + { type: "search_results", results: [] }, + messageItem("Part one. "), + messageItem("Part two."), + ]); + expect(extractAgentText(response)).toBe("Part one. Part two."); + }); + + it("should ignore non-text content parts and tool output items", () => { + const response = agentResponse([ + { type: "fetch_url_results" }, + { + type: "message", + role: "assistant", + content: [ + { type: "output_text", text: "Answer" }, + { type: "reasoning_text", text: "hidden" } as any, + ], + }, + ]); + expect(extractAgentText(response)).toBe("Answer"); + }); + + it("should return empty string when there is no message item", () => { + expect(extractAgentText(agentResponse([]))).toBe(""); }); + }); - it("should handle nested angle brackets within thinking tokens", () => { - const content = "Test content result"; - const result = stripThinkingTokens(content); - expect(result).toBe("Test result"); + describe("formatAgentResponseText", () => { + it("should append citation lines keyed by result id without touching the text", () => { + const response = agentResponse([ + { + type: "search_results", + results: [ + { id: 1, url: "https://example.com/a" }, + { id: 2, url: "https://example.com/b" }, + { id: 3, url: "https://example.com/c" }, + ], + }, + messageItem("Claim one[2]. Claim two[3][2]."), + ]); + + const formatted = formatAgentResponseText(response); + + expect(formatted).toContain("Claim one[2]. Claim two[3][2]."); + expect(formatted).toContain( + "Citations:\n[1] https://example.com/a\n[2] https://example.com/b\n[3] https://example.com/c\n" + ); }); - it("should trim the result", () => { - const content = " Remove me "; - const result = stripThinkingTokens(content); - expect(result).toBe(""); + it("should never rewrite bracketed numbers in code, LaTeX, or list references", () => { + const text = + "Use `sys.argv[1]` to read the arg[3]. In LaTeX, $x[2]$ is common.\n" + + "```python\nprint(items[2])\n```\nSee step [1] of the list."; + const response = agentResponse([ + { + type: "search_results", + results: [ + { id: 1, url: "https://example.com/a" }, + { id: 2, url: "https://example.com/b" }, + { id: 3, url: "https://example.com/c" }, + ], + }, + messageItem(text), + ]); + + const formatted = formatAgentResponseText(response); + + expect(formatted.startsWith(text)).toBe(true); + }); + + it("should leave web-prefixed markers untouched and key the block by id", () => { + const response = agentResponse([ + { + type: "search_results", + results: [{ id: 4, url: "https://example.com/only" }], + }, + messageItem("A fact[web:4]. Slice syntax arr[web:4] must survive."), + ]); + + const formatted = formatAgentResponseText(response); + + expect(formatted).toContain("A fact[web:4]. Slice syntax arr[web:4] must survive."); + expect(formatted).toContain("[4] https://example.com/only"); + }); + + it("should emit one citation line when the same id and url repeat across batches", () => { + const response = agentResponse([ + { + type: "search_results", + results: [ + { id: 1, url: "https://example.com/a" }, + { id: 2, url: "https://example.com/b" }, + ], + }, + { + type: "search_results", + results: [{ id: 1, url: "https://example.com/a" }], + }, + messageItem("Claim[1]."), + ]); + + const formatted = formatAgentResponseText(response); + + expect(formatted.match(/\[1\] https:\/\/example\.com\/a/g)).toHaveLength(1); + expect(formatted).toContain("[2] https://example.com/b"); + }); + + it("should keep ids stable across multiple search batches", () => { + const response = agentResponse([ + { + type: "search_results", + results: [ + { id: 1, url: "https://example.com/a" }, + { id: 2, url: "https://example.com/b" }, + ], + }, + { + type: "search_results", + results: [{ id: 3, url: "https://example.com/c" }], + }, + messageItem("Later claim[3]."), + ]); + + const formatted = formatAgentResponseText(response); + + expect(formatted).toContain("Later claim[3]."); + expect(formatted).toContain( + "Citations:\n[1] https://example.com/a\n[2] https://example.com/b\n[3] https://example.com/c\n" + ); }); - it("should pass through unclosed think tag unchanged", () => { - const content = "Start unclosed content"; - const result = stripThinkingTokens(content); - expect(result).toBe("Start unclosed content"); - }); + it("should fall back to positional numbering when ids are ambiguous", () => { + const response = agentResponse([ + { + type: "search_results", + results: [{ id: 1, url: "https://example.com/first-batch" }], + }, + { + type: "search_results", + results: [{ id: 1, url: "https://example.com/second-batch" }], + }, + messageItem("Ambiguous ref[1]."), + ]); + + const formatted = formatAgentResponseText(response); + + expect(formatted).toContain("Ambiguous ref[1]."); + expect(formatted).toContain("[1] https://example.com/first-batch"); + expect(formatted).toContain("[2] https://example.com/second-batch"); + }); + + it("should fall back to positional numbering when ids are missing", () => { + const response = agentResponse([ + { + type: "search_results", + results: [ + { url: "https://example.com/dup" }, + { url: "https://example.com/dup" }, + { url: "https://example.com/other" }, + ], + }, + messageItem("Answer without refs."), + ]); + + const formatted = formatAgentResponseText(response); + + expect(formatted).toContain("[1] https://example.com/dup"); + expect(formatted).toContain("[2] https://example.com/other"); + expect(formatted).not.toContain("[3]"); + }); + + it("should return plain text when there are no search results", () => { + const formatted = formatAgentResponseText( + agentResponse([messageItem("Just an answer.")]) + ); - it("should pass through orphan closing tag unchanged", () => { - const content = "Some content here"; - const result = stripThinkingTokens(content); - expect(result).toBe("Some content here"); + expect(formatted).toBe("Just an answer."); + expect(formatted).not.toContain("Citations:"); }); }); @@ -193,64 +341,28 @@ describe("Server Utility Functions", () => { }); describe("validateMessages", () => { - it("should throw if messages is not an array", () => { - expect(() => validateMessages("not-an-array", "test_tool")) - .toThrow("Invalid arguments for test_tool: 'messages' must be an array"); - }); - - it("should throw if messages is null", () => { - expect(() => validateMessages(null, "test_tool")) - .toThrow("'messages' must be an array"); - }); - - it("should throw if message is not an object", () => { - expect(() => validateMessages(["string"], "test_tool")) - .toThrow("Invalid message at index 0: must be an object"); - }); - - it("should throw if message is null", () => { - expect(() => validateMessages([null], "test_tool")) - .toThrow("Invalid message at index 0: must be an object"); - }); - - it("should throw if role is missing", () => { - expect(() => validateMessages([{ content: "test" }], "test_tool")) - .toThrow("Invalid message at index 0: 'role' must be a string"); - }); - - it("should throw if role is not a string", () => { - expect(() => validateMessages([{ role: 123, content: "test" }], "test_tool")) - .toThrow("Invalid message at index 0: 'role' must be a string"); - }); - - it("should throw if content is missing", () => { - expect(() => validateMessages([{ role: "user" }], "test_tool")) - .toThrow("Invalid message at index 0: 'content' must be a string"); - }); - - it("should throw if content is not a string", () => { - expect(() => validateMessages([{ role: "user", content: 123 }], "test_tool")) - .toThrow("Invalid message at index 0: 'content' must be a string"); - }); - - it("should throw if content is null", () => { - expect(() => validateMessages([{ role: "user", content: null }], "test_tool")) - .toThrow("Invalid message at index 0: 'content' must be a string"); - }); - - it("should pass for valid messages", () => { - expect(() => validateMessages([ + it.each([ + ["not-an-array", "Invalid arguments for test_tool: 'messages' must be an array"], + [null, "'messages' must be an array"], + [["string"], "Invalid message at index 0: must be an object"], + [[null], "Invalid message at index 0: must be an object"], + [[{ content: "test" }], "Invalid message at index 0: 'role' must be a string"], + [[{ role: 123, content: "test" }], "Invalid message at index 0: 'role' must be a string"], + [[{ role: "user" }], "Invalid message at index 0: 'content' must be a string"], + [[{ role: "user", content: 123 }], "Invalid message at index 0: 'content' must be a string"], + [[{ role: "user", content: null }], "Invalid message at index 0: 'content' must be a string"], + ])("should reject %j", (input, error) => { + expect(() => validateMessages(input, "test_tool")).toThrow(error); + }); + + it("should pass valid messages and report the index of an invalid one", () => { + const valid = [ { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there" } - ], "test_tool")).not.toThrow(); - }); - - it("should report correct index for invalid message", () => { - expect(() => validateMessages([ - { role: "user", content: "valid" }, - { role: "assistant", content: "also valid" }, - { role: "user" } // no content - ], "test_tool")).toThrow("Invalid message at index 2: 'content' must be a string"); + { role: "assistant", content: "Hi there" }, + ]; + expect(() => validateMessages(valid, "test_tool")).not.toThrow(); + expect(() => validateMessages([...valid, { role: "user" }], "test_tool")) + .toThrow("Invalid message at index 2: 'content' must be a string"); }); }); }); diff --git a/src/server.ts b/src/server.ts index d649ccb..a6f03cd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,22 +3,28 @@ import { z } from "zod"; import { fetch as undiciFetch, ProxyAgent } from "undici"; import type { Message, - ChatCompletionResponse, - ChatCompletionOptions, + AgentResponse, + AgentSearchResult, + AgentToolOptions, + AgentCallHooks, SearchResponse, - SearchRequestBody, UndiciRequestOptions } from "./types.js"; -import { ChatCompletionResponseSchema, SearchResponseSchema } from "./validation.js"; +import { AgentResponseSchema, SearchResponseSchema } from "./validation.js"; const PERPLEXITY_API_KEY = process.env.PERPLEXITY_API_KEY; const PERPLEXITY_BASE_URL = process.env.PERPLEXITY_BASE_URL || "https://api.perplexity.ai"; -const VERSION = "0.9.0"; +const VERSION = "1.0.0"; + +// Agent API presets backing each tool: https://docs.perplexity.ai/docs/agent-api/presets +export const ASK_PRESET = "fast"; +export const REASON_PRESET = "medium"; +export const RESEARCH_PRESET = "high"; export function getProxyUrl(): string | undefined { - return process.env.PERPLEXITY_PROXY || - process.env.HTTPS_PROXY || - process.env.HTTP_PROXY || + return process.env.PERPLEXITY_PROXY || + process.env.HTTPS_PROXY || + process.env.HTTP_PROXY || undefined; } @@ -57,14 +63,11 @@ export function validateMessages(messages: unknown, toolName: string): asserts m } } -export function stripThinkingTokens(content: string): string { - return content.replace(/[\s\S]*?<\/think>/g, '').trim(); -} - async function makeApiRequest( endpoint: string, body: Record, serviceOrigin: string | undefined, + signal?: AbortSignal, ): Promise { if (!PERPLEXITY_API_KEY) { throw new Error("PERPLEXITY_API_KEY environment variable is required"); @@ -76,6 +79,14 @@ async function makeApiRequest( const url = new URL(`${PERPLEXITY_BASE_URL}/${endpoint}`); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); + // An abort from the caller's signal also tears down an in-flight body stream. + if (signal) { + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener("abort", () => controller.abort(), { once: true }); + } + } let response; try { @@ -97,6 +108,10 @@ async function makeApiRequest( } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === "AbortError") { + if (signal?.aborted) { + // The caller knows whether its signal meant cancellation or deadline. + throw error; + } throw new Error(`Request timeout: Perplexity API did not respond within ${TIMEOUT_MS}ms. Consider increasing PERPLEXITY_TIMEOUT_MS.`); } throw new Error(`Network error while calling Perplexity API: ${error}`); @@ -118,7 +133,25 @@ async function makeApiRequest( return response; } -export async function consumeSSEStream(response: Response): Promise { +/** Best-effort cancellation of an agent run so an abandoned request stops billing. */ +export async function cancelAgentResponse(responseId: string, serviceOrigin?: string): Promise { + try { + await makeApiRequest(`v1/agent/${encodeURIComponent(responseId)}/cancel`, {}, serviceOrigin); + } catch { + // The run may already be terminal; nothing actionable either way. + } +} + +/** + * Consume an Agent API SSE stream and return the final response object. + * Emits progress via hooks.onProgress as reasoning events arrive. + */ +export async function consumeAgentStream( + response: Response, + hooks?: AgentCallHooks, + serviceOrigin?: string, + deadlineSignal?: AbortSignal, +): Promise { const body = response.body; if (!body) { throw new Error("Response body is null"); @@ -127,126 +160,279 @@ export async function consumeSSEStream(response: Response): Promise).getReader(); const decoder = new TextDecoder(); - let contentParts: string[] = []; - let citations: string[] | undefined; - let usage: ChatCompletionResponse["usage"] | undefined; - let id: string | undefined; - let model: string | undefined; - let created: number | undefined; let buffer = ""; + let eventName: string | undefined; + let responseId: string | undefined; + let finalResponse: unknown; + let streamError: string | undefined; + + const handleEvent = (type: string, parsed: Record) => { + const eventResponse = parsed.response as { id?: string } | undefined; + if (eventResponse?.id) { + responseId = eventResponse.id; + } + switch (type) { + case "response.completed": + finalResponse = parsed.response; + break; + case "response.failed": { + const error = parsed.error as { message?: string } | undefined; + streamError = error?.message || "Agent request failed"; + break; + } + case "response.cancelled": + streamError = "Agent request was cancelled"; + break; + case "error": { + const error = parsed.error as { message?: string } | undefined; + streamError = error?.message || (typeof parsed.message === "string" ? parsed.message : "Agent request failed"); + break; + } + case "response.reasoning.search_queries": { + const queries = Array.isArray(parsed.queries) ? parsed.queries.filter((q) => typeof q === "string") : []; + if (queries.length > 0) { + hooks?.onProgress?.({ message: `Searching: ${queries.join(" | ")}` }); + } + break; + } + case "response.reasoning.search_results": { + const results = Array.isArray(parsed.results) ? parsed.results : []; + if (results.length > 0) { + hooks?.onProgress?.({ message: `Reading ${results.length} search results` }); + } + break; + } + case "response.reasoning.fetch_url_queries": + hooks?.onProgress?.({ message: "Fetching page content" }); + break; + case "response.output_item.added": { + const item = parsed.item as { type?: string } | undefined; + if (item?.type === "message") { + hooks?.onProgress?.({ message: "Writing answer" }); + } + break; + } + } + }; + + // Stop reading as soon as a terminal event is in hand: waiting for the + // server to close the socket would let a deadline abort in the tail window + // discard an already-received (and billed) answer. + const isTerminal = () => finalResponse !== undefined || streamError !== undefined; + + try { + while (!isTerminal()) { + const { done, value } = await reader.read(); + if (done) break; - while (true) { - const { done, value } = await reader.read(); - if (done) break; + buffer += decoder.decode(value, { stream: true }); - buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + // Keep the last potentially incomplete line in the buffer + buffer = lines.pop() || ""; - const lines = buffer.split("\n"); - // Keep the last potentially incomplete line in the buffer - buffer = lines.pop() || ""; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + eventName = undefined; + continue; + } + if (trimmed.startsWith("event:")) { + eventName = trimmed.slice("event:".length).trim(); + continue; + } + if (!trimmed.startsWith("data:")) continue; + + const data = trimmed.slice("data:".length).trim(); + if (data === "[DONE]") continue; + + try { + const parsed = JSON.parse(data) as Record; + const type = typeof parsed.type === "string" ? parsed.type : eventName; + if (type) { + handleEvent(type, parsed); + } + } catch { + // Skip malformed JSON chunks (e.g. keep-alive pings) + } + if (isTerminal()) break; + } + } + if (isTerminal()) { + void reader.cancel().catch(() => {}); + } + } catch (error) { + if (hooks?.signal?.aborted || deadlineSignal?.aborted) { + if (responseId) { + // Stop the server-side run so an abandoned request stops billing. + void cancelAgentResponse(responseId, serviceOrigin); + } + if (hooks?.signal?.aborted) { + throw new Error("Request cancelled"); + } + throw error; + } + throw new Error(`Network error while streaming from Perplexity API: ${error}`); + } + + if (hooks?.signal?.aborted) { + if (responseId) { + void cancelAgentResponse(responseId, serviceOrigin); + } + throw new Error("Request cancelled"); + } - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || !trimmed.startsWith("data:")) continue; + if (streamError) { + throw new Error(`Perplexity API error: ${streamError}`); + } - const data = trimmed.slice("data:".length).trim(); - if (data === "[DONE]") continue; + if (!finalResponse) { + throw new Error("Agent stream ended without a completed response"); + } - try { - const parsed = JSON.parse(data); + try { + return AgentResponseSchema.parse(finalResponse) as AgentResponse; + } catch (error) { + throw new Error(`Invalid response from Perplexity Agent API: ${error}`); + } +} - if (parsed.id) id = parsed.id; - if (parsed.model) model = parsed.model; - if (parsed.created) created = parsed.created; - if (parsed.citations) citations = parsed.citations; - if (parsed.usage) usage = parsed.usage; +export function extractAgentText(response: AgentResponse): string { + return response.output + .filter((item) => item.type === "message") + .flatMap((item) => item.content ?? []) + .filter((part) => part.type === "output_text" && typeof part.text === "string") + .map((part) => part.text as string) + .join(""); +} - const delta = parsed.choices?.[0]?.delta; - if (delta?.content) { - contentParts.push(delta.content); - } - } catch { - // Skip malformed JSON chunks (e.g. keep-alive pings) +/** + * Append the "Citations:" block ("[n] url" lines) to the answer text, keyed + * by the numeric search-result ids that inline references point at. The + * answer body is never modified: bracketed tokens also appear in code, LaTeX, + * and slice syntax, so any rewrite risks corrupting the answer. If ids are + * missing or ambiguous, all unique source URLs are appended with positional + * numbering instead. + */ +export function formatAgentResponseText(response: AgentResponse): string { + const text = extractAgentText(response); + + const entries: Array<{ id?: number | null; url: string }> = []; + const urlById = new Map(); + let idsUsable = true; + for (const item of response.output) { + if (item.type !== "search_results" || !Array.isArray(item.results)) continue; + for (const result of item.results as AgentSearchResult[]) { + if (!result.url) continue; + entries.push({ id: result.id, url: result.url }); + if (typeof result.id !== "number") { + idsUsable = false; + continue; + } + const existing = urlById.get(result.id); + if (existing === undefined) { + urlById.set(result.id, result.url); + } else if (existing !== result.url) { + idsUsable = false; } } } - const assembled: ChatCompletionResponse = { - choices: [ - { - message: { content: contentParts.join("") }, - finish_reason: "stop", - index: 0, - }, - ], - ...(citations && { citations }), - ...(usage && { usage }), - ...(id && { id }), - ...(model && { model }), - ...(created && { created }), - }; + if (entries.length === 0) { + return text; + } - return ChatCompletionResponseSchema.parse(assembled); + let output = text + "\n\nCitations:\n"; + if (idsUsable) { + const emitted = new Set(); + for (const entry of entries) { + const id = entry.id as number; + if (emitted.has(id)) continue; + emitted.add(id); + output += `[${id}] ${entry.url}\n`; + } + return output; + } + + const seen = new Set(); + let position = 0; + for (const entry of entries) { + if (seen.has(entry.url)) continue; + seen.add(entry.url); + position += 1; + output += `[${position}] ${entry.url}\n`; + } + return output; } -export async function performChatCompletion( +function buildWebSearchTool(options?: AgentToolOptions): Record | undefined { + if (!options) return undefined; + const filters: Record = { + ...(options.search_recency_filter && { search_recency_filter: options.search_recency_filter }), + ...(options.search_domain_filter && { search_domain_filter: options.search_domain_filter }), + }; + const tool: Record = { + type: "web_search", + ...(Object.keys(filters).length > 0 && { filters }), + ...(options.search_context_size && { search_context_size: options.search_context_size }), + }; + // No override means the preset's own web_search config stays in effect. + return Object.keys(tool).length > 1 ? tool : undefined; +} + +export async function performAgentResponse( messages: Message[], - model: string = "sonar-pro", - stripThinking: boolean = false, + preset: string, serviceOrigin?: string, - options?: ChatCompletionOptions + options?: AgentToolOptions, + hooks?: AgentCallHooks ): Promise { - const useStreaming = model === "sonar-deep-research"; + const webSearchTool = buildWebSearchTool(options); const body: Record = { - model: model, - messages: messages, - ...(useStreaming && { stream: true }), - ...(options?.search_recency_filter && { search_recency_filter: options.search_recency_filter }), - ...(options?.search_domain_filter && { search_domain_filter: options.search_domain_filter }), - ...(options?.search_context_size && { web_search_options: { search_context_size: options.search_context_size } }), - ...(options?.reasoning_effort && { reasoning_effort: options.reasoning_effort }), + preset, + input: messages.map((message) => ({ + type: "message", + role: message.role, + content: message.content, + })), + // Always stream: long runs outlive intermediate proxy timeouts, and the + // reasoning events double as progress reporting. + stream: true, + ...(webSearchTool && { tools: [webSearchTool] }), }; - const response = await makeApiRequest("chat/completions", body, serviceOrigin); - - let data: ChatCompletionResponse; - try { - if (useStreaming) { - data = await consumeSSEStream(response); + // PERPLEXITY_TIMEOUT_MS bounds the whole call, not just time-to-headers: + // streamed responses return headers immediately, so a headers-only timeout + // would never fire. + const TIMEOUT_MS = parseInt(process.env.PERPLEXITY_TIMEOUT_MS || "300000", 10); + const deadline = new AbortController(); + const timeoutId = setTimeout(() => deadline.abort(), TIMEOUT_MS); + const abortDeadline = () => deadline.abort(); + if (hooks?.signal) { + if (hooks.signal.aborted) { + deadline.abort(); } else { - const json = await response.json(); - data = ChatCompletionResponseSchema.parse(json); - } - } catch (error) { - if (error instanceof z.ZodError) { - const issues = error.issues; - if (issues.some(i => i.path.includes('message') || i.path.includes('content'))) { - throw new Error("Invalid API response: missing message content"); - } - if (issues.some(i => i.path.includes('choices'))) { - throw new Error("Invalid API response: missing or empty choices array"); - } + hooks.signal.addEventListener("abort", abortDeadline, { once: true }); } - throw new Error(`Failed to parse JSON response from Perplexity API: ${error}`); } - const firstChoice = data.choices[0]; - - let messageContent = firstChoice.message.content; - - if (stripThinking) { - messageContent = stripThinkingTokens(messageContent); - } - - if (data.citations && Array.isArray(data.citations) && data.citations.length > 0) { - messageContent += "\n\nCitations:\n"; - data.citations.forEach((citation, index) => { - messageContent += `[${index + 1}] ${citation}\n`; - }); + try { + const response = await makeApiRequest("v1/agent", body, serviceOrigin, deadline.signal); + const agentResponse = await consumeAgentStream(response, hooks, serviceOrigin, deadline.signal); + return formatAgentResponseText(agentResponse); + } catch (error) { + if (hooks?.signal?.aborted) { + throw new Error("Request cancelled"); + } + if (deadline.signal.aborted) { + throw new Error(`Request timeout: Perplexity API did not respond within ${TIMEOUT_MS}ms. Consider increasing PERPLEXITY_TIMEOUT_MS.`); + } + throw error; + } finally { + clearTimeout(timeoutId); + hooks?.signal?.removeEventListener("abort", abortDeadline); } - - return messageContent; } export function formatSearchResults(data: SearchResponse): string { @@ -298,6 +484,37 @@ export async function performSearch( return formatSearchResults(data); } +interface ToolExtra { + signal?: AbortSignal; + _meta?: { progressToken?: string | number }; + sendNotification?: (notification: { + method: string; + params: Record; + }) => Promise; +} + +function buildHooks(extra: ToolExtra | undefined): AgentCallHooks { + const progressToken = extra?._meta?.progressToken; + const sendNotification = extra?.sendNotification; + let progress = 0; + return { + signal: extra?.signal, + onProgress: + progressToken !== undefined && sendNotification + ? (update) => { + void sendNotification({ + method: "notifications/progress", + params: { + progressToken, + progress: ++progress, + message: update.message, + }, + }).catch(() => {}); + } + : undefined, + }; +} + export function createPerplexityServer(serviceOrigin?: string) { const server = new McpServer( { @@ -306,10 +523,10 @@ export function createPerplexityServer(serviceOrigin?: string) { }, { instructions: - "Perplexity AI server for web-grounded search, research, and reasoning. " + + "Perplexity AI server for web-grounded search, research, and reasoning, backed by the Perplexity Agent API. " + "Use perplexity_search for finding URLs, facts, and recent news. " + "Use perplexity_ask for quick AI-answered questions with citations. Supports recency filters, domain restrictions, and search context size control. " + - "Use perplexity_research for in-depth multi-source investigation (slow, 30s+). Supports reasoning_effort parameter to control depth. " + + "Use perplexity_research for in-depth multi-source investigation (slow, can take minutes). " + "Use perplexity_reason for complex analysis requiring step-by-step logic. Supports recency filters, domain restrictions, and search context size control. " + "All tools are read-only and access live web data.", } @@ -319,59 +536,44 @@ export function createPerplexityServer(serviceOrigin?: string) { role: z.enum(["system", "user", "assistant"]).describe("Role of the message sender"), content: z.string().describe("The content of the message"), }); - + const messagesField = z.array(messageSchema).describe("Array of conversation messages"); - - const stripThinkingField = z.boolean().optional() - .describe("If true, removes ... tags and their content from the response to save context tokens. Default is false."); - + const searchRecencyFilterField = z.enum(["hour", "day", "week", "month", "year"]).optional() .describe("Filter search results by recency. Use 'hour' for very recent news, 'day' for today's updates, 'week' for this week, etc."); - + const searchDomainFilterField = z.array(z.string()).optional() .describe("Restrict search results to specific domains (e.g., ['wikipedia.org', 'arxiv.org']). Use '-' prefix for exclusion (e.g., ['-reddit.com'])."); - + const searchContextSizeField = z.enum(["low", "medium", "high"]).optional() - .describe("Controls how much web context is retrieved. 'low' (default) is fastest, 'high' provides more comprehensive results."); - - const reasoningEffortField = z.enum(["minimal", "low", "medium", "high"]).optional() - .describe("Controls depth of deep research reasoning. Higher values produce more thorough analysis."); - + .describe("Controls how much web context is retrieved. 'low' is fastest, 'high' provides more comprehensive results."); + const responseOutputSchema = { response: z.string().describe("AI-generated text response with numbered citation references"), }; // Input schemas - const messagesOnlyInputSchema = { + const askAndReasonInputSchema = { messages: messagesField, search_recency_filter: searchRecencyFilterField, search_domain_filter: searchDomainFilterField, search_context_size: searchContextSizeField, }; - const messagesWithStripThinkingInputSchema = { - messages: messagesField, - strip_thinking: stripThinkingField, - search_recency_filter: searchRecencyFilterField, - search_domain_filter: searchDomainFilterField, - search_context_size: searchContextSizeField, - }; const researchInputSchema = { messages: messagesField, - strip_thinking: stripThinkingField, - reasoning_effort: reasoningEffortField, }; server.registerTool( "perplexity_ask", { title: "Ask Perplexity", - description: "Answer a question using web-grounded AI (Sonar Pro model). " + + description: "Answer a question using web-grounded AI (Perplexity Agent API, " + ASK_PRESET + " preset). " + "Best for: quick factual questions, summaries, explanations, and general Q&A. " + "Returns a text response with numbered citations. Fastest and cheapest option. " + "Supports filtering by recency (hour/day/week/month/year), domain restrictions, and search context size. " + "For in-depth multi-source research, use perplexity_research instead. " + "For step-by-step reasoning and analysis, use perplexity_reason instead.", - inputSchema: messagesOnlyInputSchema as any, + inputSchema: askAndReasonInputSchema as any, outputSchema: responseOutputSchema as any, annotations: { readOnlyHint: true, @@ -380,8 +582,8 @@ export function createPerplexityServer(serviceOrigin?: string) { destructiveHint: false, }, }, - async (args: any) => { - const { messages, search_recency_filter, search_domain_filter, search_context_size } = args as { + async (args: any, extra: any) => { + const { messages, search_recency_filter, search_domain_filter, search_context_size } = args as { messages: Message[]; search_recency_filter?: "hour" | "day" | "week" | "month" | "year"; search_domain_filter?: string[]; @@ -393,7 +595,13 @@ export function createPerplexityServer(serviceOrigin?: string) { ...(search_domain_filter && { search_domain_filter }), ...(search_context_size && { search_context_size }), }; - const result = await performChatCompletion(messages, "sonar-pro", false, serviceOrigin, Object.keys(options).length > 0 ? options : undefined); + const result = await performAgentResponse( + messages, + ASK_PRESET, + serviceOrigin, + Object.keys(options).length > 0 ? options : undefined, + buildHooks(extra), + ); return { content: [{ type: "text" as const, text: result }], structuredContent: { response: result }, @@ -405,10 +613,10 @@ export function createPerplexityServer(serviceOrigin?: string) { "perplexity_research", { title: "Deep Research", - description: "Conduct deep, multi-source research on a topic (Sonar Deep Research model). " + + description: "Conduct deep, multi-source research on a topic (Perplexity Agent API, " + RESEARCH_PRESET + " preset). " + "Best for: literature reviews, comprehensive overviews, investigative queries needing " + "many sources. Returns a detailed response with numbered citations. " + - "Significantly slower than other tools (30+ seconds). " + + "Significantly slower than other tools (can take minutes). " + "For quick factual questions, use perplexity_ask instead. " + "For logical analysis and reasoning, use perplexity_reason instead.", inputSchema: researchInputSchema as any, @@ -420,18 +628,16 @@ export function createPerplexityServer(serviceOrigin?: string) { destructiveHint: false, }, }, - async (args: any) => { - const { messages, strip_thinking, reasoning_effort } = args as { - messages: Message[]; - strip_thinking?: boolean; - reasoning_effort?: "minimal" | "low" | "medium" | "high"; - }; + async (args: any, extra: any) => { + const { messages } = args as { messages: Message[] }; validateMessages(messages, "perplexity_research"); - const stripThinking = typeof strip_thinking === "boolean" ? strip_thinking : false; - const options = { - ...(reasoning_effort && { reasoning_effort }), - }; - const result = await performChatCompletion(messages, "sonar-deep-research", stripThinking, serviceOrigin, Object.keys(options).length > 0 ? options : undefined); + const result = await performAgentResponse( + messages, + RESEARCH_PRESET, + serviceOrigin, + undefined, + buildHooks(extra), + ); return { content: [{ type: "text" as const, text: result }], structuredContent: { response: result }, @@ -443,13 +649,13 @@ export function createPerplexityServer(serviceOrigin?: string) { "perplexity_reason", { title: "Advanced Reasoning", - description: "Analyze a question using step-by-step reasoning with web grounding (Sonar Reasoning Pro model). " + + description: "Analyze a question using step-by-step reasoning with web grounding (Perplexity Agent API, " + REASON_PRESET + " preset). " + "Best for: math, logic, comparisons, complex arguments, and tasks requiring chain-of-thought. " + "Returns a reasoned response with numbered citations. " + "Supports filtering by recency (hour/day/week/month/year), domain restrictions, and search context size. " + "For quick factual questions, use perplexity_ask instead. " + "For comprehensive multi-source research, use perplexity_research instead.", - inputSchema: messagesWithStripThinkingInputSchema as any, + inputSchema: askAndReasonInputSchema as any, outputSchema: responseOutputSchema as any, annotations: { readOnlyHint: true, @@ -458,22 +664,26 @@ export function createPerplexityServer(serviceOrigin?: string) { destructiveHint: false, }, }, - async (args: any) => { - const { messages, strip_thinking, search_recency_filter, search_domain_filter, search_context_size } = args as { + async (args: any, extra: any) => { + const { messages, search_recency_filter, search_domain_filter, search_context_size } = args as { messages: Message[]; - strip_thinking?: boolean; search_recency_filter?: "hour" | "day" | "week" | "month" | "year"; search_domain_filter?: string[]; search_context_size?: "low" | "medium" | "high"; }; validateMessages(messages, "perplexity_reason"); - const stripThinking = typeof strip_thinking === "boolean" ? strip_thinking : false; const options = { ...(search_recency_filter && { search_recency_filter }), ...(search_domain_filter && { search_domain_filter }), ...(search_context_size && { search_context_size }), }; - const result = await performChatCompletion(messages, "sonar-reasoning-pro", stripThinking, serviceOrigin, Object.keys(options).length > 0 ? options : undefined); + const result = await performAgentResponse( + messages, + REASON_PRESET, + serviceOrigin, + Object.keys(options).length > 0 ? options : undefined, + buildHooks(extra), + ); return { content: [{ type: "text" as const, text: result }], structuredContent: { response: result }, @@ -490,7 +700,7 @@ export function createPerplexityServer(serviceOrigin?: string) { country: z.string().optional() .describe("ISO 3166-1 alpha-2 country code for regional results (e.g., 'US', 'GB')"), }; - + const searchOutputSchema = { results: z.string().describe("Formatted search results, each with title, URL, snippet, and date"), }; @@ -501,7 +711,7 @@ export function createPerplexityServer(serviceOrigin?: string) { title: "Search the Web", description: "Search the web and return a ranked list of results with titles, URLs, snippets, and dates. " + "Best for: finding specific URLs, checking recent news, verifying facts, discovering sources. " + - "Returns formatted results (title, URL, snippet, date) — no AI synthesis. " + + "Returns formatted results (title, URL, snippet, date) with no AI synthesis. " + "For AI-generated answers with citations, use perplexity_ask instead.", inputSchema: searchInputSchema as any, outputSchema: searchOutputSchema as any, @@ -522,7 +732,7 @@ export function createPerplexityServer(serviceOrigin?: string) { const maxResults = typeof max_results === "number" ? max_results : 10; const maxTokensPerPage = typeof max_tokens_per_page === "number" ? max_tokens_per_page : 1024; const countryCode = typeof country === "string" ? country : undefined; - + const result = await performSearch(query, maxResults, maxTokensPerPage, countryCode, serviceOrigin); return { content: [{ type: "text" as const, text: result }], @@ -533,4 +743,3 @@ export function createPerplexityServer(serviceOrigin?: string) { return server.server; } - diff --git a/src/transport.test.ts b/src/transport.test.ts index 6dfcf7e..5c3e962 100644 --- a/src/transport.test.ts +++ b/src/transport.test.ts @@ -1,11 +1,54 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createPerplexityServer } from "./server.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createPerplexityServer, ASK_PRESET, REASON_PRESET, RESEARCH_PRESET } from "./server.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import express from "express"; import cors from "cors"; import { Server } from "http"; +function agentSseResponse(text: string): Response { + const completed = { + type: "response.completed", + response: { + id: "resp_transport_test", + status: "completed", + output: [ + { + type: "search_results", + results: [{ id: 1, url: "https://example.com/source" }], + }, + { + type: "message", + id: "msg_1", + status: "completed", + role: "assistant", + content: [{ type: "output_text", text, annotations: [] }], + }, + ], + }, + }; + const payload = `data: ${JSON.stringify(completed)}\n\ndata: [DONE]\n\n`; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(payload)); + controller.close(); + }, + }); + return { ok: true, body: stream } as unknown as Response; +} + +async function connectInMemoryClient() { + const server = createPerplexityServer(); + const client = new Client({ name: "test-client", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + return { client, server }; +} + describe("Transport Integration Tests", () => { let originalFetch: typeof global.fetch; let originalEnv: NodeJS.ProcessEnv; @@ -22,56 +65,6 @@ describe("Transport Integration Tests", () => { vi.restoreAllMocks(); }); - describe("Server Factory", () => { - it("should create a server with all tools registered", () => { - const server = createPerplexityServer(); - - expect(server).toBeDefined(); - // The server should be the underlying Server instance from McpServer - expect(typeof server.connect).toBe("function"); - expect(typeof server.close).toBe("function"); - }); - - it("should fail if PERPLEXITY_API_KEY is not set", () => { - delete process.env.PERPLEXITY_API_KEY; - - // The server creation itself doesn't fail, but tool calls should fail - const server = createPerplexityServer(); - expect(server).toBeDefined(); - }); - }); - - describe("STDIO Transport", () => { - it("should connect successfully to STDIO transport", async () => { - const server = createPerplexityServer(); - const transport = new StdioServerTransport(); - - // Mock the transport connection - const connectSpy = vi.spyOn(transport, 'start').mockResolvedValue(undefined); - const closeSpy = vi.spyOn(transport, 'close').mockImplementation(() => Promise.resolve()); - - await server.connect(transport); - - expect(connectSpy).toHaveBeenCalled(); - - // Clean up - transport.close(); - server.close(); - }); - - it("should handle STDIO transport errors gracefully", async () => { - const server = createPerplexityServer(); - const transport = new StdioServerTransport(); - - // Mock transport to throw error - vi.spyOn(transport, 'start').mockRejectedValue(new Error("Transport error")); - - await expect(server.connect(transport)).rejects.toThrow("Transport error"); - - server.close(); - }); - }); - describe("HTTP Transport", () => { let httpServer: Server; let app: express.Application; @@ -230,38 +223,6 @@ describe("Transport Integration Tests", () => { expect(data.result.content[0].text).toContain("not found"); }); - it("should handle HTTP errors properly", async () => { - app.post("/mcp", async (req, res) => { - res.status(400).json({ - jsonrpc: "2.0", - error: { code: -32600, message: "Invalid Request" }, - id: null, - }); - }); - - httpServer = app.listen(0); - const address = httpServer.address(); - const port = typeof address === 'object' && address ? address.port : 3000; - - const response = await fetch(`http://localhost:${port}/mcp`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "invalid/method", - params: {} - }), - }); - - expect(response.status).toBe(400); - const data = await response.json(); - expect(data.error.message).toBe("Invalid Request"); - }); - it("should require proper Accept headers", async () => { app.post("/mcp", async (req, res) => { const transport = new StreamableHTTPServerTransport({ @@ -305,62 +266,178 @@ describe("Transport Integration Tests", () => { }); }); - describe("Transport Comparison", () => { - it("should produce identical results for both transports", async () => { - const mockResponse = { - choices: [{ message: { content: "Identical response" } }] - }; + describe("Backward Compatibility", () => { + it("should keep the historical response shape including the citations block", async () => { + global.fetch = vi + .fn() + .mockResolvedValue(agentSseResponse("The answer[1].")); + + const { client, server } = await connectInMemoryClient(); + try { + const result: any = await client.callTool({ + name: "perplexity_ask", + arguments: { messages: [{ role: "user", content: "test" }] }, + }); - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => mockResponse, - } as Response); + expect(result.isError).toBeFalsy(); + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toContain("The answer[1]."); + expect(result.content[0].text).toContain( + "\n\nCitations:\n[1] https://example.com/source" + ); + expect(result.structuredContent.response).toBe(result.content[0].text); + } finally { + await client.close(); + await server.close(); + } + }); - // Test STDIO (we can't easily test the actual transport, but we can test the server) - const server1 = createPerplexityServer(); - const server2 = createPerplexityServer(); + it.each(["perplexity_ask", "perplexity_reason", "perplexity_research"])( + "should ignore removed legacy parameters on %s instead of rejecting them", + async (tool) => { + global.fetch = vi.fn().mockResolvedValue(agentSseResponse("ok")); - // Both should be identical server instances with same capabilities - expect(server1).toBeDefined(); - expect(server2).toBeDefined(); - - // Clean up - server1.close(); - server2.close(); - }); - }); + const { client, server } = await connectInMemoryClient(); + try { + const result: any = await client.callTool({ + name: tool, + arguments: { + messages: [{ role: "user", content: "test" }], + strip_thinking: true, + reasoning_effort: "high", + }, + }); - describe("Health Check", () => { - let healthApp: express.Application; - let healthHttpServer: Server; + expect(result.isError).toBeFalsy(); + const upstreamBody = JSON.parse( + (global.fetch as ReturnType).mock.calls[0][1].body as string + ); + expect(upstreamBody).not.toHaveProperty("strip_thinking"); + expect(upstreamBody).not.toHaveProperty("reasoning_effort"); + } finally { + await client.close(); + await server.close(); + } + } + ); - beforeEach(() => { - healthApp = express(); - }); + it("should keep the search tool response shape", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { title: "Result", url: "https://example.com", snippet: "snippet" }, + ], + }), + } as Response); - afterEach(async () => { - if (healthHttpServer) { - await new Promise((resolve) => { - healthHttpServer.close(() => resolve()); + const { client, server } = await connectInMemoryClient(); + try { + const result: any = await client.callTool({ + name: "perplexity_search", + arguments: { query: "test" }, }); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toContain("Found 1 search results"); + expect(result.structuredContent.results).toBe(result.content[0].text); + } finally { + await client.close(); + await server.close(); } }); - it("should provide health check endpoint for HTTP mode", async () => { - healthApp.get("/health", (req: express.Request, res: express.Response) => { - res.json({ status: "ok", service: "perplexity-mcp-server" }); + it("should emit progress notifications when the client requests progress", async () => { + const events = [ + { type: "response.created", response: { id: "resp_progress" } }, + { + type: "response.reasoning.search_queries", + queries: ["progress test query"], + }, + { + type: "response.completed", + response: { + id: "resp_progress", + status: "completed", + output: [ + { + type: "message", + id: "msg_1", + status: "completed", + role: "assistant", + content: [{ type: "output_text", text: "done", annotations: [] }], + }, + ], + }, + }, + ]; + const payload = + events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") + + "data: [DONE]\n\n"; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(payload)); + controller.close(); + }, }); + global.fetch = vi + .fn() + .mockResolvedValue({ ok: true, body: stream } as unknown as Response); + + const { client, server } = await connectInMemoryClient(); + try { + const progressMessages: Array = []; + const result: any = await client.callTool( + { + name: "perplexity_research", + arguments: { messages: [{ role: "user", content: "test" }] }, + }, + undefined, + { + onprogress: (p) => { + progressMessages.push(p.message); + }, + } + ); - healthHttpServer = healthApp.listen(0); - const address = healthHttpServer.address(); - const port = typeof address === 'object' && address ? address.port : 3000; + expect(result.isError).toBeFalsy(); + expect(progressMessages).toContain("Searching: progress test query"); + } finally { + await client.close(); + await server.close(); + } + }); - const response = await fetch(`http://localhost:${port}/health`); - expect(response.ok).toBe(true); - - const data = await response.json(); - expect(data.status).toBe("ok"); - expect(data.service).toBe("perplexity-mcp-server"); + it("should route each tool to its documented preset", async () => { + const cases: Array<{ tool: string; preset: string }> = [ + { tool: "perplexity_ask", preset: ASK_PRESET }, + { tool: "perplexity_reason", preset: REASON_PRESET }, + { tool: "perplexity_research", preset: RESEARCH_PRESET }, + ]; + + for (const { tool, preset } of cases) { + global.fetch = vi.fn().mockResolvedValue(agentSseResponse("ok")); + const { client, server } = await connectInMemoryClient(); + try { + const result: any = await client.callTool({ + name: tool, + arguments: { messages: [{ role: "user", content: "test" }] }, + }); + expect(result.isError).toBeFalsy(); + const upstreamBody = JSON.parse( + (global.fetch as ReturnType).mock.calls[0][1].body as string + ); + expect(upstreamBody.preset).toBe(preset); + expect( + (global.fetch as ReturnType).mock.calls[0][0] + ).toBe("https://api.perplexity.ai/v1/agent"); + } finally { + await client.close(); + await server.close(); + } + } }); }); + }); diff --git a/src/types.ts b/src/types.ts index f7d6fb9..7d6130c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,30 +5,60 @@ export interface Message { content: string; } -export interface ChatMessage { +export interface AgentInputMessage { + type: "message"; + role: string; content: string; - role?: string; } -export interface ChatChoice { - message: ChatMessage; - finish_reason?: string; - index?: number; +export interface AgentAnnotation { + type?: string | null; + url?: string | null; + title?: string | null; +} + +export interface AgentContentPart { + type: string; + text?: string | null; + annotations?: AgentAnnotation[] | null; +} + +export interface AgentSearchResult { + id?: number | null; + url?: string | null; + title?: string | null; + snippet?: string | null; + date?: string | null; +} + +export interface AgentOutputItem { + type: string; + // "message" items + role?: string | null; + content?: AgentContentPart[] | null; + // "search_results" items + results?: AgentSearchResult[] | null; } -export interface TokenUsage { - prompt_tokens?: number; - completion_tokens?: number; +export interface AgentCost { + total_cost?: number; + currency?: string; +} + +export interface AgentUsage { + input_tokens?: number; + output_tokens?: number; total_tokens?: number; + cost?: AgentCost; } -export interface ChatCompletionResponse { - choices: ChatChoice[]; - citations?: string[]; - usage?: TokenUsage; - id?: string; - model?: string; - created?: number; +export interface AgentResponse { + id?: string | null; + status?: string | null; + model?: string | null; + output: AgentOutputItem[]; + usage?: AgentUsage; + error?: { message?: string | null; type?: string | null } | null; } export interface SearchResult { @@ -56,11 +86,21 @@ export interface SearchRequestBody { country?: string; } -export interface ChatCompletionOptions { +export interface AgentToolOptions { search_recency_filter?: "hour" | "day" | "week" | "month" | "year"; search_domain_filter?: string[]; search_context_size?: "low" | "medium" | "high"; - reasoning_effort?: "minimal" | "low" | "medium" | "high"; +} + +export interface AgentProgressUpdate { + message: string; +} + +export interface AgentCallHooks { + /** Abort signal from the MCP request; triggers server-side cancellation. */ + signal?: AbortSignal; + /** Called with human-readable progress while the agent run is in flight. */ + onProgress?: (update: AgentProgressUpdate) => void; } export interface UndiciRequestOptions { diff --git a/src/validation.ts b/src/validation.ts index 537f33f..be5c85c 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -1,30 +1,55 @@ import { z } from "zod"; -export const ChatMessageSchema = z.object({ - content: z.string(), - role: z.string().optional(), -}); +// Agent API response schemas, deliberately lenient so new upstream output +// item types or fields never break the server. +export const AgentAnnotationSchema = z + .object({ + type: z.string().nullish(), + url: z.string().nullish(), + title: z.string().nullish(), + }) + .passthrough(); -export const ChatChoiceSchema = z.object({ - message: ChatMessageSchema, - finish_reason: z.string().optional(), - index: z.number().optional(), -}); +export const AgentContentPartSchema = z + .object({ + type: z.string(), + text: z.string().nullish(), + annotations: z.array(AgentAnnotationSchema).nullish(), + }) + .passthrough(); -export const TokenUsageSchema = z.object({ - prompt_tokens: z.number().optional(), - completion_tokens: z.number().optional(), - total_tokens: z.number().optional(), -}); +export const AgentSearchResultSchema = z + .object({ + id: z.number().nullish(), + url: z.string().nullish(), + title: z.string().nullish(), + snippet: z.string().nullish(), + date: z.string().nullish(), + }) + .passthrough(); -export const ChatCompletionResponseSchema = z.object({ - choices: z.array(ChatChoiceSchema).min(1), - citations: z.array(z.string()).optional(), - usage: TokenUsageSchema.optional(), - id: z.string().optional(), - model: z.string().optional(), - created: z.number().optional(), -}); +export const AgentOutputItemSchema = z + .object({ + type: z.string(), + role: z.string().nullish(), + content: z.array(AgentContentPartSchema).nullish(), + results: z.array(AgentSearchResultSchema).nullish(), + }) + .passthrough(); + +export const AgentResponseSchema = z + .object({ + id: z.string().nullish(), + status: z.string().nullish(), + model: z.string().nullish(), + output: z.array(AgentOutputItemSchema), + usage: z.unknown().optional(), + error: z + .object({ message: z.string().nullish(), type: z.string().nullish() }) + .passthrough() + .nullish(), + }) + .passthrough(); export const SearchResultSchema = z.object({ title: z.string(),