From 4bcc43318b85651767777f5addbe4e75d0e393fb Mon Sep 17 00:00:00 2001 From: Ryan Buchmayer Date: Wed, 22 Jul 2026 21:28:04 -0700 Subject: [PATCH] feat: move ask/reason/research tools from sonar models to Agent API presets The three model-backed tools now call the Agent API (POST /v1/agent) with managed presets: - perplexity_ask: sonar-pro -> fast preset - perplexity_reason: sonar-reasoning-pro -> medium preset - perplexity_research: sonar-deep-research -> high preset perplexity_search is unchanged. Tool names and output shape are unchanged, including the appended "Citations:" block: citation lines are keyed by the Agent API's sequential source ids so inline references line up, and the answer body is never modified (bracketed tokens also appear in code and LaTeX). All agent calls stream; PERPLEXITY_TIMEOUT_MS bounds the whole call, not just time-to-headers. Reasoning events are surfaced as MCP progress notifications when the client requests progress, and MCP cancellation aborts the run server-side via the cancel endpoint. Removed params: strip_thinking (the Agent API emits no think tags) and reasoning_effort (presets manage effort). Old clients that still send them are ignored gracefully rather than rejected. search_recency_filter, search_domain_filter, and search_context_size now map onto the web_search tool override. Measured on the same query set (avg): ask 15.3s/$0.030 -> 9.4s/$0.011, reason 22.2s/$0.016 -> 14.4s/$0.030, research 228.7s/$0.79 -> 115.6s/$0.62. Responses are more concise, most visibly for research. --- .claude-plugin/marketplace.json | 4 +- README.md | 14 +- package-lock.json | 12 +- package.json | 4 +- server.json | 4 +- src/index.test.ts | 1017 ++++++++++++++----------------- src/server.test.ts | 318 ++++++---- src/server.ts | 525 +++++++++++----- src/transport.test.ts | 331 ++++++---- src/types.ts | 76 ++- src/validation.ts | 69 ++- 11 files changed, 1368 insertions(+), 1006 deletions(-) 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(),