From 9dd2f2760db0a2f595ac9d6470916319efae2550 Mon Sep 17 00:00:00 2001 From: gleb-reshetnev Date: Tue, 21 Jul 2026 18:16:08 +0200 Subject: [PATCH] feat: add Agent API support to MCP tools --- .claude-plugin/marketplace.json | 5 +- README.md | 10 +- package-lock.json | 4 +- package.json | 2 +- server.json | 5 +- src/agent.test.ts | 170 +++++++ src/index.test.ts | 863 ++++---------------------------- src/server.ts | 314 +++++++----- src/transport.test.ts | 69 +++ src/types.ts | 50 +- src/validation.ts | 110 +++- 11 files changed, 656 insertions(+), 946 deletions(-) create mode 100644 src/agent.test.ts 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 @@   [![npm version](https://img.shields.io/npm/v/%40perplexity-ai%2Fmcp-server?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/@perplexity-ai/mcp-server) -The official MCP server implementation for the Perplexity API Platform, providing AI assistants with real-time web search, reasoning, and research capabilities through Sonar models and the Search API. +The official MCP server implementation for the Perplexity API Platform, providing AI assistants with real-time web search, reasoning, and research capabilities through the Agent API 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();