diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index d54c2b8..e3518e0 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": "0.10.0"
},
"plugins": [
{
"name": "perplexity",
"source": "./",
"description": "Real-time web search, reasoning, and research through Perplexity's API",
- "version": "0.9.0",
+ "version": "0.10.0",
"author": {
"name": "Perplexity AI",
"email": "api@perplexity.ai"
@@ -46,4 +46,3 @@
}
]
}
-
diff --git a/README.md b/README.md
index 5e2057d..859eed9 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 and Search API.
## Available Tools
@@ -16,13 +16,15 @@ 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 using the Agent API `fast` preset by default. 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 using the Agent API `medium` preset by default. Ideal for thorough analysis and detailed reports.
### **perplexity_reason**
-Advanced reasoning and problem-solving using the `sonar-reasoning-pro` model. Perfect for complex analytical tasks.
+Advanced reasoning and problem-solving using the Agent API `low` preset by default. Perfect for complex analytical tasks.
+
+The three Agent API tools accept optional `model` and `preset` parameters. Use `model` for a provider/model ID such as `perplexity/sonar`, or `preset` for any current Agent API preset. If both are provided, the model overrides the preset's model while retaining its other configuration.
> [!TIP]
> Available as an optional parameter for **perplexity_reason** and **perplexity_research**: `strip_thinking`
diff --git a/package-lock.json b/package-lock.json
index c5096c6..427ca00 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@perplexity-ai/mcp-server",
- "version": "0.9.0",
+ "version": "0.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@perplexity-ai/mcp-server",
- "version": "0.9.0",
+ "version": "0.10.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.21.1",
diff --git a/package.json b/package.json
index b5bf6cc..6a402bc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@perplexity-ai/mcp-server",
- "version": "0.9.0",
+ "version": "0.10.0",
"mcpName": "ai.perplexity/mcp-server",
"description": "Real-time web search, reasoning, and research through Perplexity's API",
"keywords": [
diff --git a/server.json b/server.json
index 0ebc5cd..9d7823b 100644
--- a/server.json
+++ b/server.json
@@ -3,16 +3,15 @@
"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": "0.10.0",
"packages": [
{
"registryType": "npm",
"identifier": "@perplexity-ai/mcp-server",
- "version": "0.9.0",
+ "version": "0.10.0",
"transport": {
"type": "stdio"
}
}
]
}
-
diff --git a/src/agent.test.ts b/src/agent.test.ts
new file mode 100644
index 0000000..b48fee7
--- /dev/null
+++ b/src/agent.test.ts
@@ -0,0 +1,170 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { performAgentResponse } from "./server.js";
+
+function createSseResponse(events: unknown[]): Response {
+ const body = events
+ .map((event) => `data: ${JSON.stringify(event)}\n\n`)
+ .concat("data: [DONE]\n\n")
+ .join("");
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode(body));
+ controller.close();
+ },
+ });
+
+ return { ok: true, body: stream } as Response;
+}
+
+describe("performAgentResponse", () => {
+ let originalFetch: typeof global.fetch;
+
+ beforeEach(() => {
+ originalFetch = global.fetch;
+ });
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+ });
+
+ it("streams an Agent API response using a selected model", async () => {
+ global.fetch = vi.fn().mockResolvedValue(createSseResponse([
+ { type: "response.output_text.delta", delta: "A grounded " },
+ { type: "response.output_text.delta", delta: "answer[1]." },
+ {
+ type: "response.completed",
+ response: {
+ id: "resp_123",
+ object: "response",
+ created_at: 1,
+ model: "perplexity/sonar",
+ status: "completed",
+ output: [
+ {
+ type: "search_results",
+ results: [
+ {
+ id: 1,
+ title: "Primary source",
+ url: "https://example.com/source",
+ },
+ ],
+ },
+ {
+ type: "message",
+ id: "msg_123",
+ role: "assistant",
+ content: [{ type: "output_text", text: "A grounded answer[1]." }],
+ },
+ ],
+ },
+ },
+ ]));
+
+ const messages = [{ role: "user", content: "test question" }];
+ const result = await performAgentResponse(messages, { model: "perplexity/sonar" });
+
+ expect(result).toBe(
+ "A grounded answer[1].\n\nCitations:\n[1] https://example.com/source\n",
+ );
+ expect(global.fetch).toHaveBeenCalledWith(
+ "https://api.perplexity.ai/v1/agent",
+ expect.objectContaining({
+ method: "POST",
+ headers: expect.objectContaining({
+ "Content-Type": "application/json",
+ Authorization: "Bearer test-api-key",
+ "X-Source": "pplx-mcp-server",
+ }),
+ body: JSON.stringify({
+ input: messages,
+ model: "perplexity/sonar",
+ stream: true,
+ }),
+ }),
+ );
+ });
+
+ it("applies preset search and reasoning options to the Agent API request", async () => {
+ global.fetch = vi.fn().mockResolvedValue(createSseResponse([
+ {
+ type: "response.reasoning.search_results",
+ results: [
+ { id: 1, title: "First source", url: "https://example.com/first" },
+ ],
+ },
+ {
+ type: "response.reasoning.search_results",
+ results: [
+ { id: 2, title: "Second source", url: "https://example.com/second" },
+ ],
+ },
+ {
+ type: "response.output_text.delta",
+ delta: "hiddenThe result[1][2].",
+ },
+ ]));
+
+ const messages = [{ role: "user", content: "research this" }];
+ const result = await performAgentResponse(messages, {
+ preset: "medium",
+ strip_thinking: true,
+ search_recency_filter: "week",
+ search_domain_filter: ["example.com"],
+ search_context_size: "high",
+ reasoning_effort: "high",
+ });
+
+ expect(result).toBe(
+ "The result[1][2].\n\nCitations:\n" +
+ "[1] https://example.com/first\n" +
+ "[2] https://example.com/second\n",
+ );
+ expect(global.fetch).toHaveBeenCalledWith(
+ "https://api.perplexity.ai/v1/agent",
+ expect.objectContaining({
+ body: JSON.stringify({
+ input: messages,
+ preset: "medium",
+ stream: true,
+ tools: [{
+ type: "web_search",
+ search_context_size: "high",
+ filters: {
+ search_recency_filter: "week",
+ search_domain_filter: ["example.com"],
+ },
+ }],
+ reasoning: { effort: "high" },
+ }),
+ }),
+ );
+ });
+
+ it("surfaces failed Agent API stream responses", async () => {
+ global.fetch = vi.fn().mockResolvedValue(createSseResponse([
+ {
+ type: "response.failed",
+ response: {
+ id: "resp_failed",
+ object: "response",
+ created_at: 1,
+ model: "perplexity/sonar",
+ status: "failed",
+ output: [],
+ error: {
+ type: "server_error",
+ code: "upstream_error",
+ message: "The upstream model failed",
+ },
+ },
+ },
+ ]));
+
+ await expect(performAgentResponse(
+ [{ role: "user", content: "test question" }],
+ { model: "perplexity/sonar" },
+ )).rejects.toThrow("Agent API response failed: The upstream model failed");
+ });
+});
diff --git a/src/index.test.ts b/src/index.test.ts
index 21897b8..cdff444 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -1,7 +1,7 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { formatSearchResults, performChatCompletion, performSearch } from "./server.js";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { formatSearchResults, performSearch } from "./server.js";
-describe("Perplexity MCP Server", () => {
+describe("Search API", () => {
let originalFetch: typeof global.fetch;
beforeEach(() => {
@@ -13,774 +13,121 @@ describe("Perplexity MCP Server", () => {
vi.restoreAllMocks();
});
- describe("formatSearchResults", () => {
- it("should format search results correctly", () => {
- const mockData = {
- results: [
- {
- title: "Test Result 1",
- url: "https://example.com/1",
- snippet: "This is a test snippet",
- date: "2025-01-01",
- },
- {
- title: "Test Result 2",
- url: "https://example.com/2",
- snippet: "Another snippet",
- },
- ],
- };
-
- const formatted = formatSearchResults(mockData);
-
- expect(formatted).toContain("Found 2 search results");
- expect(formatted).toContain("Test Result 1");
- expect(formatted).toContain("https://example.com/1");
- expect(formatted).toContain("This is a test snippet");
- expect(formatted).toContain("Date: 2025-01-01");
- expect(formatted).toContain("Test Result 2");
- });
-
- it("should handle empty results", () => {
- const mockData = { results: [] };
- const formatted = formatSearchResults(mockData);
- expect(formatted).toContain("Found 0 search results");
- });
-
- it("should handle missing results array", () => {
- const mockData = {} as any;
- const formatted = formatSearchResults(mockData);
- expect(formatted).toBe("No search results found.");
- });
+ it("formats search results with optional metadata", () => {
+ const formatted = formatSearchResults({
+ results: [
+ {
+ title: "Test Result 1",
+ url: "https://example.com/1",
+ snippet: "This is a test snippet",
+ date: "2025-01-01",
+ },
+ {
+ title: "Test Result 2",
+ url: "https://example.com/2",
+ },
+ ],
+ });
+
+ expect(formatted).toBe(
+ "Found 2 search results:\n\n" +
+ "1. **Test Result 1**\n" +
+ " URL: https://example.com/1\n" +
+ " This is a test snippet\n" +
+ " Date: 2025-01-01\n\n" +
+ "2. **Test Result 2**\n" +
+ " URL: https://example.com/2\n\n",
+ );
});
- 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);
-
- const messages = [{ role: "user", content: "test question" }];
- const result = await performChatCompletion(messages, "sonar-pro");
-
- expect(result).toBe("This is a test response");
- expect(global.fetch).toHaveBeenCalledWith(
- "https://api.perplexity.ai/chat/completions",
- expect.objectContaining({
- method: "POST",
- headers: expect.objectContaining({
- "Content-Type": "application/json",
- Authorization: "Bearer test-api-key",
- "X-Source": "pplx-mcp-server",
- }),
- body: JSON.stringify({
- model: "sonar-pro",
- messages,
- }),
- })
- );
- });
-
- it("should append citations when present", async () => {
- const mockResponse = {
- choices: [
- {
- message: {
- content: "Response with citations",
- },
- },
- ],
- citations: [
- "https://example.com/source1",
- "https://example.com/source2",
- ],
- };
-
- 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).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 handle API errors", async () => {
- global.fetch = vi.fn().mockResolvedValue({
- ok: false,
- status: 401,
- statusText: "Unauthorized",
- text: async () => "Invalid API key",
- } as Response);
-
- const messages = [{ role: "user", content: "test" }];
-
- await expect(performChatCompletion(messages)).rejects.toThrow(
- "Perplexity API error: 401 Unauthorized"
- );
- });
-
- it("should handle 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 () => ({ choices: [{ message: { content: "late" } }] }),
- } as Response);
- }, 200);
- });
- });
-
- const messages = [{ role: "user", content: "test" }];
-
- await expect(performChatCompletion(messages)).rejects.toThrow(
- "Request timeout"
- );
- });
-
- 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(
- "Network error while calling Perplexity API"
- );
- });
+ it("formats empty or missing search results", () => {
+ expect(formatSearchResults({ results: [] })).toBe(
+ "Found 0 search results:\n\n",
+ );
+ expect(formatSearchResults(
+ {} as Parameters[0],
+ )).toBe("No search results found.");
});
- describe("performSearch", () => {
- it("should successfully perform search", async () => {
- const mockResponse = {
- results: [
- {
- title: "Search Result",
- url: "https://example.com",
- snippet: "Test snippet",
- },
- ],
- };
-
- global.fetch = vi.fn().mockResolvedValue({
- ok: true,
- json: async () => mockResponse,
- } as Response);
-
- const result = await performSearch("test query", 10, 1024);
-
- expect(result).toContain("Found 1 search results");
- expect(result).toContain("Search Result");
- expect(global.fetch).toHaveBeenCalledWith(
- "https://api.perplexity.ai/search",
- expect.objectContaining({
- method: "POST",
- headers: expect.objectContaining({
- "Content-Type": "application/json",
- Authorization: "Bearer test-api-key",
- }),
- body: JSON.stringify({
- query: "test query",
- max_results: 10,
- max_tokens_per_page: 1024,
- }),
- })
- );
- });
-
- it("should include country parameter when provided", async () => {
- const mockResponse = { results: [] };
-
- global.fetch = vi.fn().mockResolvedValue({
- ok: true,
- json: async () => mockResponse,
- } as Response);
-
- await performSearch("test", 10, 1024, "US");
-
- expect(global.fetch).toHaveBeenCalledWith(
- "https://api.perplexity.ai/search",
- expect.objectContaining({
- body: JSON.stringify({
- query: "test",
- max_results: 10,
- max_tokens_per_page: 1024,
- country: "US",
- }),
- })
- );
- });
-
- it("should handle search API errors", async () => {
- global.fetch = vi.fn().mockResolvedValue({
- ok: false,
- status: 500,
- statusText: "Internal Server Error",
- text: async () => "Server error",
- } as Response);
-
- await expect(performSearch("test")).rejects.toThrow(
- "Perplexity API error: 500 Internal Server Error"
- );
- });
-
- 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"
- );
- });
+ it("posts search requests with regional options", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ results: [] }),
+ } as Response);
+
+ const result = await performSearch("test query", 5, 512, "US");
+
+ expect(result).toBe("Found 0 search results:\n\n");
+ expect(global.fetch).toHaveBeenCalledWith(
+ "https://api.perplexity.ai/search",
+ expect.objectContaining({
+ method: "POST",
+ headers: expect.objectContaining({
+ Authorization: "Bearer test-api-key",
+ "Content-Type": "application/json",
+ }),
+ body: JSON.stringify({
+ query: "test query",
+ max_results: 5,
+ max_tokens_per_page: 512,
+ country: "US",
+ }),
+ }),
+ );
});
- describe("API Response Validation", () => {
- it("should handle empty choices array", 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 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"
- );
- });
+ it("reports Search API errors", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 401,
+ statusText: "Unauthorized",
+ text: async () => "Invalid API key",
+ } as Response);
+
+ await expect(performSearch("test query")).rejects.toThrow(
+ "Perplexity API error: 401 Unauthorized",
+ );
});
- 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);
+ it("reports invalid Search API responses", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ results: "not-an-array" }),
+ } as Response);
- const messages = [{ role: "user", content: "test" }];
-
- await expect(performChatCompletion(messages)).rejects.toThrow(
- "Failed to parse JSON response"
- );
- });
-
- it("should handle error text parse failures", async () => {
- global.fetch = vi.fn().mockResolvedValue({
- ok: false,
- status: 500,
- statusText: "Internal Server Error",
- text: async () => {
- throw new Error("Cannot read error");
- },
- } as unknown as Response);
-
- const messages = [{ role: "user", content: "test" }];
-
- await expect(performChatCompletion(messages)).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 () => {
- 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" },
- ],
- };
-
- const formatted = formatSearchResults(mockData);
-
- expect(formatted).toContain("Test");
- expect(formatted).toContain("Test 2");
- 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");
- });
+ await expect(performSearch("test query")).rejects.toThrow(
+ "Failed to parse JSON response from Perplexity Search API",
+ );
});
- 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.");
+ it("reports Search API network errors", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("Network failure"));
- // 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.");
- });
+ await expect(performSearch("test query")).rejects.toThrow(
+ "Network error while calling Perplexity API",
+ );
});
- 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");
- });
+ it("applies the request timeout to Search API calls", async () => {
+ const originalTimeout = process.env.PERPLEXITY_TIMEOUT_MS;
+ process.env.PERPLEXITY_TIMEOUT_MS = "10";
+ global.fetch = vi.fn().mockImplementation((_url, options) => (
+ new Promise((_resolve, reject) => {
+ const signal = options?.signal as AbortSignal;
+ signal.addEventListener("abort", () => {
+ reject(new DOMException("The operation was aborted", "AbortError"));
+ });
+ })
+ ));
+
+ try {
+ await expect(performSearch("test query")).rejects.toThrow("Request timeout");
+ } finally {
+ if (originalTimeout === undefined) {
+ delete process.env.PERPLEXITY_TIMEOUT_MS;
+ } else {
+ process.env.PERPLEXITY_TIMEOUT_MS = originalTimeout;
+ }
+ }
});
});
diff --git a/src/server.ts b/src/server.ts
index d649ccb..66c9746 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -3,17 +3,29 @@ import { z } from "zod";
import { fetch as undiciFetch, ProxyAgent } from "undici";
import type {
Message,
- ChatCompletionResponse,
- ChatCompletionOptions,
SearchResponse,
SearchRequestBody,
+ AgentModelSelection,
+ AgentRequestOptions,
+ AgentResponseResult,
+ AgentSearchResult,
UndiciRequestOptions
} from "./types.js";
-import { ChatCompletionResponseSchema, SearchResponseSchema } from "./validation.js";
+import {
+ AgentCompletedEventSchema,
+ AgentFailedEventSchema,
+ AgentMessageOutputSchema,
+ AgentOutputTextDeltaEventSchema,
+ AgentRequestSchema,
+ AgentSearchResultsEventSchema,
+ AgentSearchResultsOutputSchema,
+ 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 = "0.10.0";
+type DefaultAgentPreset = "fast" | "low" | "medium";
export function getProxyUrl(): string | undefined {
return process.env.PERPLEXITY_PROXY ||
@@ -118,7 +130,38 @@ async function makeApiRequest(
return response;
}
-export async function consumeSSEStream(response: Response): Promise {
+function extractAgentOutput(output: unknown[]): AgentResponseResult {
+ const contentParts: string[] = [];
+ const searchResults: AgentSearchResult[] = [];
+
+ for (const item of output) {
+ const message = AgentMessageOutputSchema.safeParse(item);
+ if (message.success) {
+ contentParts.push(...message.data.content.map((content) => content.text));
+ continue;
+ }
+
+ const results = AgentSearchResultsOutputSchema.safeParse(item);
+ if (results.success) {
+ searchResults.push(...results.data.results);
+ }
+ }
+
+ return { content: contentParts.join(""), searchResults };
+}
+
+function mergeSearchResults(
+ existing: AgentSearchResult[],
+ incoming: AgentSearchResult[],
+): AgentSearchResult[] {
+ const resultsById = new Map(existing.map((result) => [String(result.id), result]));
+ for (const result of incoming) {
+ resultsById.set(String(result.id), result);
+ }
+ return [...resultsById.values()];
+}
+
+export async function consumeAgentSSEStream(response: Response): Promise {
const body = response.body;
if (!body) {
throw new Error("Response body is null");
@@ -126,127 +169,118 @@ 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;
+ const contentParts: string[] = [];
+ let searchResults: AgentSearchResult[] = [];
+ let completedContent = "";
let buffer = "";
+ const processLine = (line: string) => {
+ const trimmed = line.trim();
+ if (!trimmed.startsWith("data:")) return;
+
+ const data = trimmed.slice("data:".length).trim();
+ if (!data || data === "[DONE]") return;
+
+ let event: unknown;
+ try {
+ event = JSON.parse(data);
+ } catch {
+ return;
+ }
+
+ const delta = AgentOutputTextDeltaEventSchema.safeParse(event);
+ if (delta.success) {
+ contentParts.push(delta.data.delta);
+ return;
+ }
+
+ const streamedResults = AgentSearchResultsEventSchema.safeParse(event);
+ if (streamedResults.success) {
+ searchResults = mergeSearchResults(searchResults, streamedResults.data.results);
+ return;
+ }
+
+ const completed = AgentCompletedEventSchema.safeParse(event);
+ if (completed.success) {
+ const output = extractAgentOutput(completed.data.response.output);
+ completedContent = output.content;
+ searchResults = mergeSearchResults(searchResults, output.searchResults);
+ return;
+ }
+
+ const failed = AgentFailedEventSchema.safeParse(event);
+ if (failed.success) {
+ const message = failed.data.response.error?.message ?? "Unknown error";
+ throw new Error(`Agent API response failed: ${message}`);
+ }
+ };
+
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
-
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 || !trimmed.startsWith("data:")) continue;
-
- const data = trimmed.slice("data:".length).trim();
- if (data === "[DONE]") continue;
-
- try {
- const parsed = JSON.parse(data);
-
- 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;
-
- const delta = parsed.choices?.[0]?.delta;
- if (delta?.content) {
- contentParts.push(delta.content);
- }
- } catch {
- // Skip malformed JSON chunks (e.g. keep-alive pings)
- }
- }
+ lines.forEach(processLine);
}
+ buffer += decoder.decode();
+ if (buffer) processLine(buffer);
- const assembled: ChatCompletionResponse = {
- choices: [
- {
- message: { content: contentParts.join("") },
- finish_reason: "stop",
- index: 0,
- },
- ],
- ...(citations && { citations }),
- ...(usage && { usage }),
- ...(id && { id }),
- ...(model && { model }),
- ...(created && { created }),
+ return {
+ content: contentParts.length > 0 ? contentParts.join("") : completedContent,
+ searchResults,
};
+}
- return ChatCompletionResponseSchema.parse(assembled);
+function formatAgentResponse(result: AgentResponseResult, stripThinking: boolean): string {
+ let content = stripThinking ? stripThinkingTokens(result.content) : result.content;
+ if (result.searchResults.length === 0) return content;
+
+ content += "\n\nCitations:\n";
+ for (const citation of result.searchResults) {
+ content += `[${citation.id}] ${citation.url}\n`;
+ }
+ return content;
}
-export async function performChatCompletion(
+export async function performAgentResponse(
messages: Message[],
- model: string = "sonar-pro",
- stripThinking: boolean = false,
+ options: AgentRequestOptions,
serviceOrigin?: string,
- options?: ChatCompletionOptions
): Promise {
- const useStreaming = model === "sonar-deep-research";
-
- 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 }),
+ const filters = {
+ ...(options.search_recency_filter && {
+ search_recency_filter: options.search_recency_filter,
+ }),
+ ...(options.search_domain_filter && {
+ search_domain_filter: options.search_domain_filter,
+ }),
};
-
- const response = await makeApiRequest("chat/completions", body, serviceOrigin);
-
- let data: ChatCompletionResponse;
- try {
- if (useStreaming) {
- data = await consumeSSEStream(response);
- } 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");
- }
- }
- 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`;
- });
- }
-
- return messageContent;
+ const hasSearchOptions =
+ Object.keys(filters).length > 0 || options.search_context_size !== undefined;
+ const tools = hasSearchOptions
+ ? [{
+ type: "web_search" as const,
+ ...(options.search_context_size && {
+ search_context_size: options.search_context_size,
+ }),
+ ...(Object.keys(filters).length > 0 && { filters }),
+ }]
+ : undefined;
+ const body = AgentRequestSchema.parse({
+ model: options.model,
+ preset: options.preset,
+ input: messages,
+ stream: true,
+ tools,
+ ...(options.reasoning_effort && {
+ reasoning: { effort: options.reasoning_effort },
+ }),
+ });
+ const response = await makeApiRequest("v1/agent", body, serviceOrigin);
+ const result = await consumeAgentSSEStream(response);
+ return formatAgentResponse(result, options.strip_thinking ?? false);
}
export function formatSearchResults(data: SearchResponse): string {
@@ -336,20 +370,46 @@ export function createPerplexityServer(serviceOrigin?: string) {
const reasoningEffortField = z.enum(["minimal", "low", "medium", "high"]).optional()
.describe("Controls depth of deep research reasoning. Higher values produce more thorough analysis.");
+
+ const modelField = z.string().min(1).optional()
+ .describe(
+ "Agent API model in provider/model format (for example, 'perplexity/sonar'). " +
+ "Overrides the tool's default preset.",
+ );
+
+ const presetField = z.string().min(1).optional()
+ .describe(
+ "Agent API preset (for example, 'fast', 'low', or 'medium'). " +
+ "Uses the tool's default preset when both preset and model are omitted.",
+ );
const responseOutputSchema = {
response: z.string().describe("AI-generated text response with numbered citation references"),
};
+ const selectAgentModel = (
+ model: string | undefined,
+ preset: string | undefined,
+ defaultPreset: DefaultAgentPreset,
+ ): AgentModelSelection => ({
+ ...(!model && !preset && { preset: defaultPreset }),
+ ...(preset && { preset }),
+ ...(model && { model }),
+ });
+
// Input schemas
const messagesOnlyInputSchema = {
messages: messagesField,
+ model: modelField,
+ preset: presetField,
search_recency_filter: searchRecencyFilterField,
search_domain_filter: searchDomainFilterField,
search_context_size: searchContextSizeField,
};
const messagesWithStripThinkingInputSchema = {
messages: messagesField,
+ model: modelField,
+ preset: presetField,
strip_thinking: stripThinkingField,
search_recency_filter: searchRecencyFilterField,
search_domain_filter: searchDomainFilterField,
@@ -357,6 +417,8 @@ export function createPerplexityServer(serviceOrigin?: string) {
};
const researchInputSchema = {
messages: messagesField,
+ model: modelField,
+ preset: presetField,
strip_thinking: stripThinkingField,
reasoning_effort: reasoningEffortField,
};
@@ -365,7 +427,7 @@ export function createPerplexityServer(serviceOrigin?: string) {
"perplexity_ask",
{
title: "Ask Perplexity",
- description: "Answer a question using web-grounded AI (Sonar Pro model). " +
+ description: "Answer a question using Perplexity's Agent API (fast preset by default). " +
"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. " +
@@ -381,19 +443,22 @@ export function createPerplexityServer(serviceOrigin?: string) {
},
},
async (args: any) => {
- const { messages, search_recency_filter, search_domain_filter, search_context_size } = args as {
+ const { messages, model, preset, search_recency_filter, search_domain_filter, search_context_size } = args as {
messages: Message[];
+ model?: string;
+ preset?: string;
search_recency_filter?: "hour" | "day" | "week" | "month" | "year";
search_domain_filter?: string[];
search_context_size?: "low" | "medium" | "high";
};
validateMessages(messages, "perplexity_ask");
- const options = {
+ const options: AgentRequestOptions = {
+ ...selectAgentModel(model, preset, "fast"),
...(search_recency_filter && { search_recency_filter }),
...(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, options, serviceOrigin);
return {
content: [{ type: "text" as const, text: result }],
structuredContent: { response: result },
@@ -405,7 +470,7 @@ 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 using Perplexity's Agent API (medium preset by default). " +
"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). " +
@@ -421,17 +486,20 @@ export function createPerplexityServer(serviceOrigin?: string) {
},
},
async (args: any) => {
- const { messages, strip_thinking, reasoning_effort } = args as {
+ const { messages, model, preset, strip_thinking, reasoning_effort } = args as {
messages: Message[];
+ model?: string;
+ preset?: string;
strip_thinking?: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
};
validateMessages(messages, "perplexity_research");
- const stripThinking = typeof strip_thinking === "boolean" ? strip_thinking : false;
- const options = {
+ const options: AgentRequestOptions = {
+ ...selectAgentModel(model, preset, "medium"),
+ strip_thinking: strip_thinking ?? false,
...(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, options, serviceOrigin);
return {
content: [{ type: "text" as const, text: result }],
structuredContent: { response: result },
@@ -443,7 +511,7 @@ 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 Perplexity's Agent API (low preset by default). " +
"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. " +
@@ -459,21 +527,24 @@ export function createPerplexityServer(serviceOrigin?: string) {
},
},
async (args: any) => {
- const { messages, strip_thinking, search_recency_filter, search_domain_filter, search_context_size } = args as {
+ const { messages, model, preset, strip_thinking, search_recency_filter, search_domain_filter, search_context_size } = args as {
messages: Message[];
+ model?: string;
+ preset?: string;
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 = {
+ const options: AgentRequestOptions = {
+ ...selectAgentModel(model, preset, "low"),
+ strip_thinking: strip_thinking ?? false,
...(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, options, serviceOrigin);
return {
content: [{ type: "text" as const, text: result }],
structuredContent: { response: result },
@@ -533,4 +604,3 @@ export function createPerplexityServer(serviceOrigin?: string) {
return server.server;
}
-
diff --git a/src/transport.test.ts b/src/transport.test.ts
index 6dfcf7e..e7a7a89 100644
--- a/src/transport.test.ts
+++ b/src/transport.test.ts
@@ -161,6 +161,14 @@ describe("Transport Integration Tests", () => {
// Verify tool schema structure
expect(data.result.tools[0].inputSchema).toBeDefined();
expect(data.result.tools[0].description).toBeDefined();
+
+ const generativeTools = data.result.tools.filter(
+ (tool: { name: string }) => tool.name !== "perplexity_search",
+ );
+ for (const tool of generativeTools) {
+ expect(tool.inputSchema.properties.model).toBeDefined();
+ expect(tool.inputSchema.properties.preset).toBeDefined();
+ }
});
it("should handle tool calls via HTTP with real transport", async () => {
@@ -230,6 +238,67 @@ describe("Transport Integration Tests", () => {
expect(data.result.content[0].text).toContain("not found");
});
+ it("routes generative tool calls through the Agent API", async () => {
+ app.post("/mcp", async (req, res) => {
+ const transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: undefined,
+ enableJsonResponse: true,
+ });
+ res.on("close", () => transport.close());
+
+ const server = createPerplexityServer();
+ await server.connect(transport);
+ await transport.handleRequest(req, res, req.body);
+ });
+
+ httpServer = app.listen(0);
+ const address = httpServer.address();
+ const port = typeof address === "object" && address ? address.port : 3000;
+ const apiFetch = vi.fn().mockResolvedValue(new Response(
+ [
+ `data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Agent answer" })}\n\n`,
+ "data: [DONE]\n\n",
+ ].join(""),
+ { status: 200, headers: { "Content-Type": "text/event-stream" } },
+ ));
+ global.fetch = apiFetch;
+
+ const response = await originalFetch(`http://localhost:${port}/mcp`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Accept": "application/json, text/event-stream",
+ },
+ body: JSON.stringify({
+ jsonrpc: "2.0",
+ id: 3,
+ method: "tools/call",
+ params: {
+ name: "perplexity_ask",
+ arguments: {
+ messages: [{ role: "user", content: "test question" }],
+ model: "perplexity/sonar",
+ },
+ },
+ }),
+ });
+ const data = await response.json();
+
+ expect(data.result.content).toEqual([{ type: "text", text: "Agent answer" }]);
+ expect(data.result.structuredContent).toEqual({ response: "Agent answer" });
+ expect(apiFetch).toHaveBeenCalledWith(
+ "https://api.perplexity.ai/v1/agent",
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({
+ input: [{ role: "user", content: "test question" }],
+ model: "perplexity/sonar",
+ stream: true,
+ }),
+ }),
+ );
+ });
+
it("should handle HTTP errors properly", async () => {
app.post("/mcp", async (req, res) => {
res.status(400).json({
diff --git a/src/types.ts b/src/types.ts
index f7d6fb9..e2e71e9 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -5,32 +5,6 @@ export interface Message {
content: string;
}
-export interface ChatMessage {
- content: string;
- role?: string;
-}
-
-export interface ChatChoice {
- message: ChatMessage;
- finish_reason?: string;
- index?: number;
-}
-
-export interface TokenUsage {
- prompt_tokens?: number;
- completion_tokens?: number;
- total_tokens?: number;
-}
-
-export interface ChatCompletionResponse {
- choices: ChatChoice[];
- citations?: string[];
- usage?: TokenUsage;
- id?: string;
- model?: string;
- created?: number;
-}
-
export interface SearchResult {
title: string;
url: string;
@@ -56,13 +30,35 @@ export interface SearchRequestBody {
country?: string;
}
-export interface ChatCompletionOptions {
+export interface AgentOptions {
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 AgentModelSelection {
+ model?: string;
+ preset?: string;
+}
+
+export interface AgentRequestOptions extends AgentModelSelection, AgentOptions {
+ strip_thinking?: boolean;
+}
+
+export interface AgentSearchResult {
+ id: number | string;
+ title: string;
+ url: string;
+ snippet?: string | null;
+ date?: string | null;
+}
+
+export interface AgentResponseResult {
+ content: string;
+ searchResults: AgentSearchResult[];
+}
+
export interface UndiciRequestOptions {
[key: string]: unknown;
dispatcher?: ProxyAgent;
diff --git a/src/validation.ts b/src/validation.ts
index 537f33f..fb7df0a 100644
--- a/src/validation.ts
+++ b/src/validation.ts
@@ -1,31 +1,5 @@
import { z } from "zod";
-export const ChatMessageSchema = z.object({
- content: z.string(),
- role: z.string().optional(),
-});
-
-export const ChatChoiceSchema = z.object({
- message: ChatMessageSchema,
- finish_reason: z.string().optional(),
- index: z.number().optional(),
-});
-
-export const TokenUsageSchema = z.object({
- prompt_tokens: z.number().optional(),
- completion_tokens: z.number().optional(),
- total_tokens: z.number().optional(),
-});
-
-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 SearchResultSchema = z.object({
title: z.string(),
url: z.string(),
@@ -43,3 +17,87 @@ export const SearchResponseSchema = z.object({
query: z.string().optional(),
usage: SearchUsageSchema.optional(),
});
+
+export const AgentInputMessageSchema = z.object({
+ role: z.enum(["system", "user", "assistant"]),
+ content: z.string(),
+});
+
+export const AgentSearchResultSchema = z.object({
+ id: z.union([z.number(), z.string()]),
+ title: z.string(),
+ url: z.string(),
+ snippet: z.string().nullable().optional(),
+ date: z.string().nullable().optional(),
+}).passthrough();
+
+export const AgentWebSearchToolSchema = z.object({
+ type: z.literal("web_search"),
+ search_context_size: z.enum(["low", "medium", "high"]).optional(),
+ filters: z.object({
+ search_recency_filter: z.enum(["hour", "day", "week", "month", "year"]).optional(),
+ search_domain_filter: z.array(z.string()).optional(),
+ }).optional(),
+});
+
+export const AgentRequestSchema = z.object({
+ input: z.array(AgentInputMessageSchema),
+ model: z.string().min(1).optional(),
+ preset: z.string().min(1).optional(),
+ stream: z.literal(true),
+ tools: z.array(AgentWebSearchToolSchema).optional(),
+ reasoning: z.object({
+ effort: z.enum(["minimal", "low", "medium", "high"]),
+ }).optional(),
+}).refine((request) => request.model || request.preset, {
+ message: "Either model or preset is required",
+});
+
+export const AgentOutputTextSchema = z.object({
+ type: z.literal("output_text"),
+ text: z.string(),
+}).passthrough();
+
+export const AgentMessageOutputSchema = z.object({
+ type: z.literal("message"),
+ content: z.array(AgentOutputTextSchema),
+}).passthrough();
+
+export const AgentSearchResultsOutputSchema = z.object({
+ type: z.literal("search_results"),
+ results: z.array(AgentSearchResultSchema),
+}).passthrough();
+
+export const AgentResponseSchema = z.object({
+ id: z.string(),
+ object: z.literal("response"),
+ created_at: z.number(),
+ model: z.string(),
+ status: z.enum(["completed", "failed", "incomplete", "in_progress", "queued", "cancelled"]),
+ output: z.array(z.unknown()),
+ error: z.object({
+ message: z.string(),
+ code: z.string().optional(),
+ type: z.string().optional(),
+ }).nullable().optional(),
+}).passthrough();
+
+export const AgentOutputTextDeltaEventSchema = z.object({
+ type: z.literal("response.output_text.delta"),
+ delta: z.string(),
+}).passthrough();
+
+export const AgentSearchResultsEventSchema = z.object({
+ type: z.literal("response.reasoning.search_results"),
+ results: z.array(AgentSearchResultSchema),
+}).passthrough();
+
+export const AgentCompletedEventSchema = z.object({
+ type: z.literal("response.completed"),
+ response: AgentResponseSchema,
+}).passthrough();
+
+export const AgentFailedEventSchema = z.object({
+ type: z.literal("response.failed"),
+ response: AgentResponseSchema,
+}).passthrough();