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 @@
[](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(),