From b35b75337e1aa5d413a2437921212f1151301100 Mon Sep 17 00:00:00 2001 From: Ompragash Viswanathan Date: Thu, 27 Nov 2025 11:50:43 +0530 Subject: [PATCH 1/8] feat: improve TypeScript type safety and add structured logging --- README.md | 1 + src/http.ts | 11 +-- src/index.test.ts | 4 +- src/logger.ts | 85 +++++++++++++++++++ src/server.test.ts | 187 ++++++++++++++++++++++++++++++++++++++++++ src/server.ts | 58 +++++++------ src/transport.test.ts | 2 +- src/types.ts | 74 +++++++++++++++++ 8 files changed, 388 insertions(+), 34 deletions(-) create mode 100644 src/logger.ts create mode 100644 src/server.test.ts create mode 100644 src/types.ts diff --git a/README.md b/README.md index 1cd1f9b..41b7df2 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Advanced reasoning and problem-solving using the `sonar-reasoning-pro` model. Pe 1. Get your Perplexity API Key from the [API Portal](https://www.perplexity.ai/account/api/group) 2. Set it as an environment variable: `PERPLEXITY_API_KEY=your_key_here` 3. (Optional) Set a timeout for requests: `PERPLEXITY_TIMEOUT_MS=600000`. The default is 5 minutes. +4. (Optional) Set log level for debugging: `PERPLEXITY_LOG_LEVEL=DEBUG|INFO|WARN|ERROR`. The default is ERROR. ### Claude Code diff --git a/src/http.ts b/src/http.ts index b759a2c..fb3a3f2 100644 --- a/src/http.ts +++ b/src/http.ts @@ -4,11 +4,12 @@ import express from "express"; import cors from "cors"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { createPerplexityServer } from "./server.js"; +import { logger } from "./logger.js"; // Check for required API key const PERPLEXITY_API_KEY = process.env.PERPLEXITY_API_KEY; if (!PERPLEXITY_API_KEY) { - console.error("Error: PERPLEXITY_API_KEY environment variable is required"); + logger.error("PERPLEXITY_API_KEY environment variable is required"); process.exit(1); } @@ -62,7 +63,7 @@ app.all("/mcp", async (req, res) => { await transport.handleRequest(req, res, req.body); } catch (error) { - console.error("Error handling MCP request:", error); + logger.error("Error handling MCP request", { error: String(error) }); if (!res.headersSent) { res.status(500).json({ jsonrpc: "2.0", @@ -84,10 +85,10 @@ app.get("/health", (req, res) => { * Start the HTTP server */ app.listen(PORT, BIND_ADDRESS, () => { - console.log(`Perplexity MCP Server listening on http://${BIND_ADDRESS}:${PORT}/mcp`); - console.log(`Allowed origins: ${ALLOWED_ORIGINS.join(", ")}`); + logger.info(`Perplexity MCP Server listening on http://${BIND_ADDRESS}:${PORT}/mcp`); + logger.info(`Allowed origins: ${ALLOWED_ORIGINS.join(", ")}`); }).on("error", (error) => { - console.error("Server error:", error); + logger.error("Server error", { error: String(error) }); process.exit(1); }); diff --git a/src/index.test.ts b/src/index.test.ts index 45f3eb9..31c6691 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -48,7 +48,7 @@ describe("Perplexity MCP Server", () => { }); it("should handle missing results array", () => { - const mockData = {}; + const mockData = {} as any; const formatted = formatSearchResults(mockData); expect(formatted).toBe("No search results found."); }); @@ -588,7 +588,7 @@ describe("Perplexity MCP Server", () => { { title: null, url: "https://example.com", snippet: undefined }, { title: "Valid", url: null, snippet: "snippet", date: undefined }, ], - }; + } as any; const formatted = formatSearchResults(mockData); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..a0ad805 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,85 @@ +/** + * Simple structured logger for the Perplexity MCP Server + * Outputs to stderr to avoid interfering with STDIO transport + */ + +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, +} + +const LOG_LEVEL_NAMES: Record = { + [LogLevel.DEBUG]: "DEBUG", + [LogLevel.INFO]: "INFO", + [LogLevel.WARN]: "WARN", + [LogLevel.ERROR]: "ERROR", +}; + +/** + * Gets the configured log level from environment variable + * Defaults to ERROR to minimize noise in production + */ +function getLogLevel(): LogLevel { + const level = process.env.PERPLEXITY_LOG_LEVEL?.toUpperCase(); + switch (level) { + case "DEBUG": + return LogLevel.DEBUG; + case "INFO": + return LogLevel.INFO; + case "WARN": + return LogLevel.WARN; + case "ERROR": + return LogLevel.ERROR; + default: + return LogLevel.ERROR; + } +} + +const currentLogLevel = getLogLevel(); + +/** + * Formats a log message with timestamp and level + */ +function formatMessage(level: LogLevel, message: string, meta?: Record): string { + const timestamp = new Date().toISOString(); + const levelName = LOG_LEVEL_NAMES[level]; + + if (meta && Object.keys(meta).length > 0) { + return `[${timestamp}] ${levelName}: ${message} ${JSON.stringify(meta)}`; + } + + return `[${timestamp}] ${levelName}: ${message}`; +} + +/** + * Logs a message if the configured log level allows it + */ +function log(level: LogLevel, message: string, meta?: Record): void { + if (level >= currentLogLevel) { + const formatted = formatMessage(level, message, meta); + console.error(formatted); // Use stderr to avoid interfering with STDIO + } +} + +/** + * Structured logger interface + */ +export const logger = { + debug(message: string, meta?: Record): void { + log(LogLevel.DEBUG, message, meta); + }, + + info(message: string, meta?: Record): void { + log(LogLevel.INFO, message, meta); + }, + + warn(message: string, meta?: Record): void { + log(LogLevel.WARN, message, meta); + }, + + error(message: string, meta?: Record): void { + log(LogLevel.ERROR, message, meta); + }, +}; diff --git a/src/server.test.ts b/src/server.test.ts new file mode 100644 index 0000000..3b6e052 --- /dev/null +++ b/src/server.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { stripThinkingTokens, getProxyUrl, proxyAwareFetch } from "./server.js"; + +describe("Server Utility Functions", () => { + describe("stripThinkingTokens", () => { + it("should remove thinking tokens from content", () => { + const content = "Hello This is internal thinking world!"; + const result = stripThinkingTokens(content); + expect(result).toBe("Hello world!"); + }); + + it("should handle multiple thinking tokens", () => { + const content = "First thought Hello Second thought world!"; + const result = stripThinkingTokens(content); + expect(result).toBe("Hello world!"); + }); + + it("should handle multiline thinking tokens", () => { + const content = "Start \nMultiple\nLines\nOf\nThinking\n End"; + const result = stripThinkingTokens(content); + expect(result).toBe("Start End"); + }); + + it("should handle content without thinking tokens", () => { + const content = "No thinking tokens here!"; + const result = stripThinkingTokens(content); + expect(result).toBe("No thinking tokens here!"); + }); + + it("should handle empty content", () => { + const result = stripThinkingTokens(""); + expect(result).toBe(""); + }); + + it("should handle nested angle brackets within thinking tokens", () => { + const content = "Test content result"; + const result = stripThinkingTokens(content); + expect(result).toBe("Test result"); + }); + + it("should trim the result", () => { + const content = " Remove me "; + const result = stripThinkingTokens(content); + expect(result).toBe(""); + }); + }); + + describe("getProxyUrl", () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("should return PERPLEXITY_PROXY when set", () => { + process.env.PERPLEXITY_PROXY = "http://perplexity-proxy:8080"; + process.env.HTTPS_PROXY = "http://https-proxy:8080"; + process.env.HTTP_PROXY = "http://http-proxy:8080"; + + const result = getProxyUrl(); + expect(result).toBe("http://perplexity-proxy:8080"); + }); + + it("should return HTTPS_PROXY when PERPLEXITY_PROXY not set", () => { + delete process.env.PERPLEXITY_PROXY; + process.env.HTTPS_PROXY = "http://https-proxy:8080"; + process.env.HTTP_PROXY = "http://http-proxy:8080"; + + const result = getProxyUrl(); + expect(result).toBe("http://https-proxy:8080"); + }); + + it("should return HTTP_PROXY when PERPLEXITY_PROXY and HTTPS_PROXY not set", () => { + delete process.env.PERPLEXITY_PROXY; + delete process.env.HTTPS_PROXY; + process.env.HTTP_PROXY = "http://http-proxy:8080"; + + const result = getProxyUrl(); + expect(result).toBe("http://http-proxy:8080"); + }); + + it("should return undefined when no proxy set", () => { + delete process.env.PERPLEXITY_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.HTTP_PROXY; + + const result = getProxyUrl(); + expect(result).toBeUndefined(); + }); + + it("should prioritize PERPLEXITY_PROXY over others", () => { + process.env.PERPLEXITY_PROXY = "http://specific-proxy:8080"; + process.env.HTTPS_PROXY = "http://general-proxy:8080"; + + const result = getProxyUrl(); + expect(result).toBe("http://specific-proxy:8080"); + }); + }); + + describe("proxyAwareFetch", () => { + let originalEnv: NodeJS.ProcessEnv; + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalEnv = { ...process.env }; + originalFetch = global.fetch; + }); + + afterEach(() => { + process.env = originalEnv; + global.fetch = originalFetch; + }); + + it("should use native fetch when no proxy is configured", async () => { + delete process.env.PERPLEXITY_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.HTTP_PROXY; + + const mockResponse = new Response("test", { status: 200 }); + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const result = await proxyAwareFetch("https://api.example.com/test"); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.example.com/test", + {} + ); + expect(result).toBe(mockResponse); + }); + + it("should use undici with proxy when proxy is configured", async () => { + process.env.PERPLEXITY_PROXY = "http://proxy:8080"; + + const mockResponse = { + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ data: "test" }), + }; + + // We can't easily mock undici's ProxyAgent, but we can verify the function works + // This test verifies the code path executes without error + // In a real scenario, you'd want to test with a real proxy server + + // For now, just verify the function signature works + expect(proxyAwareFetch).toBeDefined(); + expect(typeof proxyAwareFetch).toBe("function"); + }); + + it("should pass through request options to native fetch", async () => { + delete process.env.PERPLEXITY_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.HTTP_PROXY; + + const mockResponse = new Response("test", { status: 200 }); + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + const options: RequestInit = { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ test: "data" }), + }; + + await proxyAwareFetch("https://api.example.com/test", options); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.example.com/test", + options + ); + }); + + it("should handle fetch errors properly", async () => { + delete process.env.PERPLEXITY_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.HTTP_PROXY; + + global.fetch = vi.fn().mockRejectedValue(new Error("Network error")); + + await expect(proxyAwareFetch("https://api.example.com/test")) + .rejects.toThrow("Network error"); + }); + }); +}); diff --git a/src/server.ts b/src/server.ts index 23d13d3..1ee56da 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { fetch as undiciFetch, ProxyAgent } from "undici"; +import type { + Message, + ChatCompletionResponse, + SearchResponse, + SearchResult, + SearchRequestBody, + UndiciRequestOptions +} from "./types.js"; // Retrieve the Perplexity API key from environment variables const PERPLEXITY_API_KEY = process.env.PERPLEXITY_API_KEY; @@ -8,10 +16,10 @@ const PERPLEXITY_API_KEY = process.env.PERPLEXITY_API_KEY; /** * Gets the proxy URL from environment variables. * Checks PERPLEXITY_PROXY, HTTPS_PROXY, HTTP_PROXY in order. - * + * * @returns {string | undefined} The proxy URL if configured, undefined otherwise */ -function getProxyUrl(): string | undefined { +export function getProxyUrl(): string | undefined { return process.env.PERPLEXITY_PROXY || process.env.HTTPS_PROXY || process.env.HTTP_PROXY || @@ -21,21 +29,22 @@ function getProxyUrl(): string | undefined { /** * Creates a proxy-aware fetch function. * Uses undici with ProxyAgent when a proxy is configured, otherwise uses native fetch. - * + * * @param {string} url - The URL to fetch * @param {RequestInit} options - Fetch options * @returns {Promise} The fetch response */ -async function proxyAwareFetch(url: string, options: RequestInit = {}): Promise { +export async function proxyAwareFetch(url: string, options: RequestInit = {}): Promise { const proxyUrl = getProxyUrl(); - + if (proxyUrl) { // Use undici with ProxyAgent when proxy is configured const proxyAgent = new ProxyAgent(proxyUrl); - const response = await undiciFetch(url, { + const undiciOptions: UndiciRequestOptions = { ...options, dispatcher: proxyAgent, - } as any); + }; + const response = await undiciFetch(url, undiciOptions); // Cast to native Response type for compatibility return response as unknown as Response; } else { @@ -48,11 +57,11 @@ async function proxyAwareFetch(url: string, options: RequestInit = {}): Promise< * Validates an array of message objects for chat completion tools. * Ensures each message has a valid role and content field. * - * @param {any} messages - The messages to validate + * @param {unknown} messages - The messages to validate * @param {string} toolName - The name of the tool calling this validation (for error messages) * @throws {Error} If messages is not an array or if any message is invalid */ -function validateMessages(messages: any, toolName: string): void { +function validateMessages(messages: unknown, toolName: string): asserts messages is Message[] { if (!Array.isArray(messages)) { throw new Error(`Invalid arguments for ${toolName}: 'messages' must be an array`); } @@ -78,7 +87,7 @@ function validateMessages(messages: any, toolName: string): void { * @param {string} content - The content to process * @returns {string} The content with thinking tokens removed */ -function stripThinkingTokens(content: string): string { +export function stripThinkingTokens(content: string): string { return content.replace(/[\s\S]*?<\/think>/g, '').trim(); } @@ -86,14 +95,14 @@ function stripThinkingTokens(content: string): string { * Performs a chat completion by sending a request to the Perplexity API. * Appends citations to the returned message content if they exist. * - * @param {Array<{ role: string; content: string }>} messages - An array of message objects. + * @param {Message[]} messages - An array of message objects. * @param {string} model - The model to use for the completion. * @param {boolean} stripThinking - If true, removes ... tags from the response. * @returns {Promise} The chat completion result with appended citations. * @throws Will throw an error if the API request fails. */ export async function performChatCompletion( - messages: Array<{ role: string; content: string }>, + messages: Message[], model: string = "sonar-pro", stripThinking: boolean = false ): Promise { @@ -148,9 +157,9 @@ export async function performChatCompletion( } // Attempt to parse the JSON response from the API - let data; + let data: ChatCompletionResponse; try { - data = await response.json(); + data = await response.json() as ChatCompletionResponse; } catch (jsonError) { throw new Error(`Failed to parse JSON response from Perplexity API: ${jsonError}`); } @@ -176,7 +185,7 @@ export async function performChatCompletion( // If citations are provided, append them to the message content if (data.citations && Array.isArray(data.citations) && data.citations.length > 0) { messageContent += "\n\nCitations:\n"; - data.citations.forEach((citation: string, index: number) => { + data.citations.forEach((citation, index) => { messageContent += `[${index + 1}] ${citation}\n`; }); } @@ -187,17 +196,17 @@ export async function performChatCompletion( /** * Formats search results from the Perplexity Search API into a readable string. * - * @param {any} data - The search response data from the API. + * @param {SearchResponse} data - The search response data from the API. * @returns {string} Formatted search results. */ -export function formatSearchResults(data: any): string { +export function formatSearchResults(data: SearchResponse): string { if (!data.results || !Array.isArray(data.results)) { return "No search results found."; } let formattedResults = `Found ${data.results.length} search results:\n\n`; - - data.results.forEach((result: any, index: number) => { + + data.results.forEach((result, index) => { formattedResults += `${index + 1}. **${result.title}**\n`; formattedResults += ` URL: ${result.url}\n`; if (result.snippet) { @@ -236,16 +245,13 @@ export async function performSearch( const TIMEOUT_MS = parseInt(process.env.PERPLEXITY_TIMEOUT_MS || "300000", 10); const url = new URL("https://api.perplexity.ai/search"); - const body: any = { + const body: SearchRequestBody = { query: query, max_results: maxResults, max_tokens_per_page: maxTokensPerPage, + ...(country && { country }), }; - if (country) { - body.country = country; - } - const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); @@ -282,9 +288,9 @@ export async function performSearch( ); } - let data; + let data: SearchResponse; try { - data = await response.json(); + data = await response.json() as SearchResponse; } catch (jsonError) { throw new Error(`Failed to parse JSON response from Perplexity Search API: ${jsonError}`); } diff --git a/src/transport.test.ts b/src/transport.test.ts index 165d7f8..6dfcf7e 100644 --- a/src/transport.test.ts +++ b/src/transport.test.ts @@ -152,7 +152,7 @@ describe("Transport Integration Tests", () => { expect(data.result.tools).toHaveLength(4); // Verify all four tools are present - const toolNames = data.result.tools.map((t: any) => t.name); + const toolNames = data.result.tools.map((t: { name: string }) => t.name); expect(toolNames).toContain("perplexity_ask"); expect(toolNames).toContain("perplexity_research"); expect(toolNames).toContain("perplexity_reason"); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..4b7c4a0 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,74 @@ +/** + * Type definitions for the Perplexity MCP Server + */ + +/** + * Represents a single message in a conversation + */ +export interface Message { + role: string; + content: string; +} + +/** + * Response structure from Perplexity chat completion API + */ +export interface ChatCompletionResponse { + choices: Array<{ + message: { + content: string; + role?: string; + }; + finish_reason?: string; + index?: number; + }>; + citations?: string[]; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + id?: string; + model?: string; + created?: number; +} + +/** + * A single search result from the Perplexity Search API + */ +export interface SearchResult { + title: string; + url: string; + snippet?: string; + date?: string; + score?: number; +} + +/** + * Response structure from Perplexity Search API + */ +export interface SearchResponse { + results: SearchResult[]; + query?: string; + usage?: { + tokens?: number; + }; +} + +/** + * Request body for Perplexity Search API + */ +export interface SearchRequestBody { + query: string; + max_results: number; + max_tokens_per_page: number; + country?: string; +} + +/** + * Options for undici fetch with proxy support + */ +export interface UndiciRequestOptions { + [key: string]: any; + dispatcher?: any; // ProxyAgent type from undici +} From 1316e82d164f141a61ed2ba6c5f12e4c4f149825 Mon Sep 17 00:00:00 2001 From: Ompragash Viswanathan Date: Thu, 27 Nov 2025 12:13:25 +0530 Subject: [PATCH 2/8] fixed type safety issues and extracted inline types for reusability --- src/index.test.ts | 20 ++++++++-------- src/logger.ts | 10 +++++++- src/server.ts | 33 ++++++++++--------------- src/types.ts | 61 ++++++++++++++++++++++------------------------- src/validation.ts | 45 ++++++++++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 64 deletions(-) create mode 100644 src/validation.ts diff --git a/src/index.test.ts b/src/index.test.ts index 31c6691..ac0a686 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -260,7 +260,7 @@ describe("Perplexity MCP Server", () => { const messages = [{ role: "user", content: "test" }]; await expect(performChatCompletion(messages)).rejects.toThrow( - "missing or empty choices array" + "Invalid API response" ); }); @@ -273,7 +273,7 @@ describe("Perplexity MCP Server", () => { const messages = [{ role: "user", content: "test" }]; await expect(performChatCompletion(messages)).rejects.toThrow( - "missing message content" + "Invalid API response" ); }); @@ -286,7 +286,7 @@ describe("Perplexity MCP Server", () => { const messages = [{ role: "user", content: "test" }]; await expect(performChatCompletion(messages)).rejects.toThrow( - "missing or empty choices array" + "Invalid API response" ); }); @@ -299,7 +299,7 @@ describe("Perplexity MCP Server", () => { const messages = [{ role: "user", content: "test" }]; await expect(performChatCompletion(messages)).rejects.toThrow( - "missing message content" + "Invalid API response" ); }); @@ -312,7 +312,7 @@ describe("Perplexity MCP Server", () => { const messages = [{ role: "user", content: "test" }]; await expect(performChatCompletion(messages)).rejects.toThrow( - "missing or empty choices array" + "Invalid API response" ); }); @@ -325,7 +325,7 @@ describe("Perplexity MCP Server", () => { const messages = [{ role: "user", content: "test" }]; await expect(performChatCompletion(messages)).rejects.toThrow( - "missing message content" + "Invalid API response" ); }); @@ -359,10 +359,10 @@ describe("Perplexity MCP Server", () => { } as Response); const messages = [{ role: "user", content: "test" }]; - const result = await performChatCompletion(messages); - expect(result).toBe("Response"); - expect(result).not.toContain("Citations:"); + await expect(performChatCompletion(messages)).rejects.toThrow( + "Invalid API response" + ); }); }); @@ -378,7 +378,7 @@ describe("Perplexity MCP Server", () => { const messages = [{ role: "user", content: "test" }]; await expect(performChatCompletion(messages)).rejects.toThrow( - "Failed to parse JSON response" + "Invalid API response" ); }); diff --git a/src/logger.ts b/src/logger.ts index a0ad805..7c63535 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -39,6 +39,14 @@ function getLogLevel(): LogLevel { const currentLogLevel = getLogLevel(); +function safeStringify(obj: unknown): string { + try { + return JSON.stringify(obj); + } catch { + return "[Unstringifiable]"; + } +} + /** * Formats a log message with timestamp and level */ @@ -47,7 +55,7 @@ function formatMessage(level: LogLevel, message: string, meta?: Record 0) { - return `[${timestamp}] ${levelName}: ${message} ${JSON.stringify(meta)}`; + return `[${timestamp}] ${levelName}: ${message} ${safeStringify(meta)}`; } return `[${timestamp}] ${levelName}: ${message}`; diff --git a/src/server.ts b/src/server.ts index 1ee56da..b7a8072 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,10 +5,10 @@ import type { Message, ChatCompletionResponse, SearchResponse, - SearchResult, SearchRequestBody, UndiciRequestOptions } from "./types.js"; +import { ChatCompletionResponseSchema, SearchResponseSchema } from "./validation.js"; // Retrieve the Perplexity API key from environment variables const PERPLEXITY_API_KEY = process.env.PERPLEXITY_API_KEY; @@ -47,10 +47,10 @@ export async function proxyAwareFetch(url: string, options: RequestInit = {}): P const response = await undiciFetch(url, undiciOptions); // Cast to native Response type for compatibility return response as unknown as Response; - } else { - // Use native fetch when no proxy is configured - return fetch(url, options); } + + // Use native fetch when no proxy is configured + return fetch(url, options); } /** @@ -156,23 +156,15 @@ export async function performChatCompletion( ); } - // Attempt to parse the JSON response from the API let data: ChatCompletionResponse; try { - data = await response.json() as ChatCompletionResponse; - } catch (jsonError) { - throw new Error(`Failed to parse JSON response from Perplexity API: ${jsonError}`); - } - - // Validate response structure - if (!data.choices || !Array.isArray(data.choices) || data.choices.length === 0) { - throw new Error("Invalid API response: missing or empty choices array"); + const json = await response.json(); + data = ChatCompletionResponseSchema.parse(json); + } catch (error) { + throw new Error(`Invalid API response: ${error}`); } const firstChoice = data.choices[0]; - if (!firstChoice.message || typeof firstChoice.message.content !== 'string') { - throw new Error("Invalid API response: missing message content"); - } // Directly retrieve the main message content from the response let messageContent = firstChoice.message.content; @@ -290,9 +282,10 @@ export async function performSearch( let data: SearchResponse; try { - data = await response.json() as SearchResponse; - } catch (jsonError) { - throw new Error(`Failed to parse JSON response from Perplexity Search API: ${jsonError}`); + const json = await response.json(); + data = SearchResponseSchema.parse(json); + } catch (error) { + throw new Error(`Invalid API response: ${error}`); } return formatSearchResults(data); @@ -301,7 +294,7 @@ export async function performSearch( /** * Creates and configures the Perplexity MCP server with all tools. * This factory function is transport-agnostic and returns a configured server instance. - * + * * @returns The configured MCP server instance */ export function createPerplexityServer() { diff --git a/src/types.ts b/src/types.ts index 4b7c4a0..dc214c5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,8 @@ * Type definitions for the Perplexity MCP Server */ +import type { ProxyAgent } from "undici"; + /** * Represents a single message in a conversation */ @@ -10,32 +12,32 @@ export interface Message { content: string; } -/** - * Response structure from Perplexity chat completion API - */ +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: Array<{ - message: { - content: string; - role?: string; - }; - finish_reason?: string; - index?: number; - }>; + choices: ChatChoice[]; citations?: string[]; - usage?: { - prompt_tokens?: number; - completion_tokens?: number; - total_tokens?: number; - }; + usage?: TokenUsage; id?: string; model?: string; created?: number; } -/** - * A single search result from the Perplexity Search API - */ export interface SearchResult { title: string; url: string; @@ -44,20 +46,16 @@ export interface SearchResult { score?: number; } -/** - * Response structure from Perplexity Search API - */ +export interface SearchUsage { + tokens?: number; +} + export interface SearchResponse { results: SearchResult[]; query?: string; - usage?: { - tokens?: number; - }; + usage?: SearchUsage; } -/** - * Request body for Perplexity Search API - */ export interface SearchRequestBody { query: string; max_results: number; @@ -65,10 +63,7 @@ export interface SearchRequestBody { country?: string; } -/** - * Options for undici fetch with proxy support - */ export interface UndiciRequestOptions { - [key: string]: any; - dispatcher?: any; // ProxyAgent type from undici + [key: string]: unknown; + dispatcher?: ProxyAgent; } diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..537f33f --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,45 @@ +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(), + snippet: z.string().optional(), + date: z.string().optional(), + score: z.number().optional(), +}); + +export const SearchUsageSchema = z.object({ + tokens: z.number().optional(), +}); + +export const SearchResponseSchema = z.object({ + results: z.array(SearchResultSchema), + query: z.string().optional(), + usage: SearchUsageSchema.optional(), +}); From 7364dd41f667f4c75b34ab34123a553dae0d3da7 Mon Sep 17 00:00:00 2001 From: Kesku Date: Wed, 17 Dec 2025 20:36:02 +0100 Subject: [PATCH 3/8] keep actions at v6 --- .github/workflows/publish-mcp.yml | 2 +- .github/workflows/publish.yml | 4 ++-- .github/workflows/test.yml | 4 ++-- .github/workflows/validate-plugin.yml | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index 43f7ea4..752ba17 100644 --- a/.github/workflows/publish-mcp.yml +++ b/.github/workflows/publish-mcp.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install MCP Publisher run: | diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bc0714f..6656fd7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,10 +17,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index db4c2cd..e3466db 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,10 +11,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20' diff --git a/.github/workflows/validate-plugin.yml b/.github/workflows/validate-plugin.yml index b9e1e64..10733b5 100644 --- a/.github/workflows/validate-plugin.yml +++ b/.github/workflows/validate-plugin.yml @@ -17,10 +17,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20' From fb0cc8f8d0a58888d2c0cb00ef8491b4e426c87b Mon Sep 17 00:00:00 2001 From: Kesku Date: Wed, 17 Dec 2025 20:36:14 +0100 Subject: [PATCH 4/8] fix: preserve specific error messages with Zod validation --- src/index.test.ts | 16 ++++++++-------- src/server.ts | 13 +++++++++++-- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index ac0a686..b0e789a 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -260,7 +260,7 @@ describe("Perplexity MCP Server", () => { const messages = [{ role: "user", content: "test" }]; await expect(performChatCompletion(messages)).rejects.toThrow( - "Invalid API response" + "missing or empty choices array" ); }); @@ -273,7 +273,7 @@ describe("Perplexity MCP Server", () => { const messages = [{ role: "user", content: "test" }]; await expect(performChatCompletion(messages)).rejects.toThrow( - "Invalid API response" + "missing message content" ); }); @@ -286,7 +286,7 @@ describe("Perplexity MCP Server", () => { const messages = [{ role: "user", content: "test" }]; await expect(performChatCompletion(messages)).rejects.toThrow( - "Invalid API response" + "missing or empty choices array" ); }); @@ -299,7 +299,7 @@ describe("Perplexity MCP Server", () => { const messages = [{ role: "user", content: "test" }]; await expect(performChatCompletion(messages)).rejects.toThrow( - "Invalid API response" + "missing message content" ); }); @@ -312,7 +312,7 @@ describe("Perplexity MCP Server", () => { const messages = [{ role: "user", content: "test" }]; await expect(performChatCompletion(messages)).rejects.toThrow( - "Invalid API response" + "missing or empty choices array" ); }); @@ -325,7 +325,7 @@ describe("Perplexity MCP Server", () => { const messages = [{ role: "user", content: "test" }]; await expect(performChatCompletion(messages)).rejects.toThrow( - "Invalid API response" + "missing message content" ); }); @@ -361,7 +361,7 @@ describe("Perplexity MCP Server", () => { const messages = [{ role: "user", content: "test" }]; await expect(performChatCompletion(messages)).rejects.toThrow( - "Invalid API response" + "Failed to parse JSON response" ); }); }); @@ -378,7 +378,7 @@ describe("Perplexity MCP Server", () => { const messages = [{ role: "user", content: "test" }]; await expect(performChatCompletion(messages)).rejects.toThrow( - "Invalid API response" + "Failed to parse JSON response" ); }); diff --git a/src/server.ts b/src/server.ts index b7a8072..a28ae88 100644 --- a/src/server.ts +++ b/src/server.ts @@ -161,7 +161,16 @@ export async function performChatCompletion( const json = await response.json(); data = ChatCompletionResponseSchema.parse(json); } catch (error) { - throw new Error(`Invalid API response: ${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]; @@ -285,7 +294,7 @@ export async function performSearch( const json = await response.json(); data = SearchResponseSchema.parse(json); } catch (error) { - throw new Error(`Invalid API response: ${error}`); + throw new Error(`Failed to parse JSON response from Perplexity Search API: ${error}`); } return formatSearchResults(data); From 8534da4aea64eef5d5eb6bf99f33584c11f2e784 Mon Sep 17 00:00:00 2001 From: Kesku Date: Wed, 17 Dec 2025 20:46:14 +0100 Subject: [PATCH 5/8] fix placeholder proxyAwareFetch test --- src/server.test.ts | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/server.test.ts b/src/server.test.ts index 3b6e052..acee5ff 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -132,23 +132,18 @@ describe("Server Utility Functions", () => { expect(result).toBe(mockResponse); }); - it("should use undici with proxy when proxy is configured", async () => { + it("should NOT use native fetch when proxy is configured", async () => { process.env.PERPLEXITY_PROXY = "http://proxy:8080"; - const mockResponse = { - ok: true, - status: 200, - statusText: "OK", - json: async () => ({ data: "test" }), - }; + global.fetch = vi.fn().mockResolvedValue(new Response("test")); - // We can't easily mock undici's ProxyAgent, but we can verify the function works - // This test verifies the code path executes without error - // In a real scenario, you'd want to test with a real proxy server + try { + await proxyAwareFetch("https://api.example.com/test"); + } catch { + // Expected to fail - no proxy server is configured + } - // For now, just verify the function signature works - expect(proxyAwareFetch).toBeDefined(); - expect(typeof proxyAwareFetch).toBe("function"); + expect(global.fetch).not.toHaveBeenCalled(); }); it("should pass through request options to native fetch", async () => { From 352f2fc31d86fb7f9119f89457113bda9c8619ef Mon Sep 17 00:00:00 2001 From: Kesku Date: Wed, 17 Dec 2025 20:46:46 +0100 Subject: [PATCH 6/8] test: add stripThinkingTokens edge cases for malformed tags --- src/server.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/server.test.ts b/src/server.test.ts index acee5ff..a5e4ff1 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -43,6 +43,18 @@ describe("Server Utility Functions", () => { const result = stripThinkingTokens(content); expect(result).toBe(""); }); + + it("should pass through unclosed think tag unchanged", () => { + const content = "Start unclosed content"; + const result = stripThinkingTokens(content); + expect(result).toBe("Start unclosed content"); + }); + + it("should pass through orphan closing tag unchanged", () => { + const content = "Some content here"; + const result = stripThinkingTokens(content); + expect(result).toBe("Some content here"); + }); }); describe("getProxyUrl", () => { From 31c2674f402d339020b274e12428e1db53e77a63 Mon Sep 17 00:00:00 2001 From: Kesku Date: Wed, 17 Dec 2025 20:47:20 +0100 Subject: [PATCH 7/8] test: add performSearch timeout and network error tests --- src/index.test.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/index.test.ts b/src/index.test.ts index b0e789a..80332ca 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -248,6 +248,41 @@ describe("Perplexity MCP Server", () => { "Perplexity Search 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 Search API" + ); + }); }); describe("API Response Validation", () => { From e546489166235f8317c109e8ca95556b2c0e324a Mon Sep 17 00:00:00 2001 From: Kesku Date: Wed, 17 Dec 2025 20:49:06 +0100 Subject: [PATCH 8/8] test: add validateMessages coverage --- src/server.test.ts | 64 +++++++++++++++++++++++++++++++++++++++++++++- src/server.ts | 2 +- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/server.test.ts b/src/server.test.ts index a5e4ff1..d1efb3f 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { stripThinkingTokens, getProxyUrl, proxyAwareFetch } from "./server.js"; +import { stripThinkingTokens, getProxyUrl, proxyAwareFetch, validateMessages } from "./server.js"; describe("Server Utility Functions", () => { describe("stripThinkingTokens", () => { @@ -191,4 +191,66 @@ describe("Server Utility Functions", () => { .rejects.toThrow("Network error"); }); }); + + describe("validateMessages", () => { + it("should throw if messages is not an array", () => { + expect(() => validateMessages("not-an-array", "test_tool")) + .toThrow("Invalid arguments for test_tool: 'messages' must be an array"); + }); + + it("should throw if messages is null", () => { + expect(() => validateMessages(null, "test_tool")) + .toThrow("'messages' must be an array"); + }); + + it("should throw if message is not an object", () => { + expect(() => validateMessages(["string"], "test_tool")) + .toThrow("Invalid message at index 0: must be an object"); + }); + + it("should throw if message is null", () => { + expect(() => validateMessages([null], "test_tool")) + .toThrow("Invalid message at index 0: must be an object"); + }); + + it("should throw if role is missing", () => { + expect(() => validateMessages([{ content: "test" }], "test_tool")) + .toThrow("Invalid message at index 0: 'role' must be a string"); + }); + + it("should throw if role is not a string", () => { + expect(() => validateMessages([{ role: 123, content: "test" }], "test_tool")) + .toThrow("Invalid message at index 0: 'role' must be a string"); + }); + + it("should throw if content is missing", () => { + expect(() => validateMessages([{ role: "user" }], "test_tool")) + .toThrow("Invalid message at index 0: 'content' must be a string"); + }); + + it("should throw if content is not a string", () => { + expect(() => validateMessages([{ role: "user", content: 123 }], "test_tool")) + .toThrow("Invalid message at index 0: 'content' must be a string"); + }); + + it("should throw if content is null", () => { + expect(() => validateMessages([{ role: "user", content: null }], "test_tool")) + .toThrow("Invalid message at index 0: 'content' must be a string"); + }); + + it("should pass for valid messages", () => { + expect(() => validateMessages([ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there" } + ], "test_tool")).not.toThrow(); + }); + + it("should report correct index for invalid message", () => { + expect(() => validateMessages([ + { role: "user", content: "valid" }, + { role: "assistant", content: "also valid" }, + { role: "user" } // no content + ], "test_tool")).toThrow("Invalid message at index 2: 'content' must be a string"); + }); + }); }); diff --git a/src/server.ts b/src/server.ts index a28ae88..4ae5dc2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -61,7 +61,7 @@ export async function proxyAwareFetch(url: string, options: RequestInit = {}): P * @param {string} toolName - The name of the tool calling this validation (for error messages) * @throws {Error} If messages is not an array or if any message is invalid */ -function validateMessages(messages: unknown, toolName: string): asserts messages is Message[] { +export function validateMessages(messages: unknown, toolName: string): asserts messages is Message[] { if (!Array.isArray(messages)) { throw new Error(`Invalid arguments for ${toolName}: 'messages' must be an array`); }