Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish-mcp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Install MCP Publisher
run: |
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/validate-plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 6 additions & 5 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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",
Expand All @@ -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);
});

45 changes: 40 additions & 5 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
});
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -359,10 +394,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(
"Failed to parse JSON response"
);
});
});

Expand Down Expand Up @@ -588,7 +623,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);

Expand Down
93 changes: 93 additions & 0 deletions src/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* 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, string> = {
[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();

function safeStringify(obj: unknown): string {
try {
return JSON.stringify(obj);
} catch {
return "[Unstringifiable]";
}
}

/**
* Formats a log message with timestamp and level
*/
function formatMessage(level: LogLevel, message: string, meta?: Record<string, unknown>): string {
const timestamp = new Date().toISOString();
const levelName = LOG_LEVEL_NAMES[level];

if (meta && Object.keys(meta).length > 0) {
return `[${timestamp}] ${levelName}: ${message} ${safeStringify(meta)}`;
}

return `[${timestamp}] ${levelName}: ${message}`;
}

/**
* Logs a message if the configured log level allows it
*/
function log(level: LogLevel, message: string, meta?: Record<string, unknown>): 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<string, unknown>): void {
log(LogLevel.DEBUG, message, meta);
},

info(message: string, meta?: Record<string, unknown>): void {
log(LogLevel.INFO, message, meta);
},

warn(message: string, meta?: Record<string, unknown>): void {
log(LogLevel.WARN, message, meta);
},

error(message: string, meta?: Record<string, unknown>): void {
log(LogLevel.ERROR, message, meta);
},
};
Loading