Skip to content
Open
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
66 changes: 64 additions & 2 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ describe("Perplexity MCP Server", () => {
);
});

it("should handle error text parse failures", async () => {
it("should handle error text parse failures with a generic message", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
Expand All @@ -430,8 +430,70 @@ describe("Perplexity MCP Server", () => {

const messages = [{ role: "user", content: "test" }];

// Sanitized: the client should only see the status + statusText, never
// the underlying "Unable to parse error response" or raw exception text.
await expect(performChatCompletion(messages)).rejects.toThrow(
"Unable to parse error response"
"Perplexity API error: 500 Internal Server Error"
);
await expect(performChatCompletion(messages)).rejects.not.toThrow(
"Cannot read error"
);
});

// CWE-200 regression: thrown error must not contain JSON-parser exception text.
it("should not expose JSON parse error details", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => {
throw new Error("Invalid JSON containing secret_token=abc123");
},
} as unknown as Response);

const messages = [{ role: "user", content: "test" }];

await expect(performChatCompletion(messages)).rejects.toThrow(
"Failed to parse JSON response from Perplexity API"
);
await expect(performChatCompletion(messages)).rejects.not.toThrow(
"secret_token=abc123"
);
});

// CWE-200 regression: thrown error must not contain network exception text.
it("should not expose network error details", async () => {
global.fetch = vi.fn().mockRejectedValue(
new Error("Network failure with credential=private-token")
);

const messages = [{ role: "user", content: "test" }];

await expect(performChatCompletion(messages)).rejects.toThrow(
"Network error while calling Perplexity API"
);
await expect(performChatCompletion(messages)).rejects.not.toThrow(
"credential=private-token"
);
});

// CWE-200 regression: thrown error must not contain the upstream response body.
it("should not expose upstream error response details", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: "Internal Server Error",
text: async () => "internal_trace=abc123; account=private-tier",
} as Response);

const messages = [{ role: "user", content: "test" }];

await expect(performChatCompletion(messages)).rejects.toThrow(
"Perplexity API error: 500 Internal Server Error"
);
await expect(performChatCompletion(messages)).rejects.not.toThrow(
"internal_trace=abc123"
);
await expect(performChatCompletion(messages)).rejects.not.toThrow(
"private-tier"
);
});

Expand Down
23 changes: 19 additions & 4 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
UndiciRequestOptions
} from "./types.js";
import { ChatCompletionResponseSchema, SearchResponseSchema } from "./validation.js";
import { logger } from "./logger.js";

const PERPLEXITY_API_KEY = process.env.PERPLEXITY_API_KEY;
const PERPLEXITY_BASE_URL = process.env.PERPLEXITY_BASE_URL || "https://api.perplexity.ai";
Expand Down Expand Up @@ -99,7 +100,10 @@ async function makeApiRequest(
if (error instanceof Error && error.name === "AbortError") {
throw new Error(`Request timeout: Perplexity API did not respond within ${TIMEOUT_MS}ms. Consider increasing PERPLEXITY_TIMEOUT_MS.`);
}
throw new Error(`Network error while calling Perplexity API: ${error}`);
// CWE-200: do not leak raw exception text to MCP clients. Log server-side
// for operator diagnostics, return a stable generic message to the caller.
logger.error("Network error while calling Perplexity API", { error: String(error) });
throw new Error("Network error while calling Perplexity API");
}
clearTimeout(timeoutId);

Expand All @@ -110,8 +114,15 @@ async function makeApiRequest(
} catch (parseError) {
errorText = "Unable to parse error response";
}
// CWE-200: keep upstream response body out of the client-facing error.
// Operators still see the full body in structured server logs.
logger.error("Perplexity API error", {
status: response.status,
statusText: response.statusText,
body: errorText,
});
throw new Error(
`Perplexity API error: ${response.status} ${response.statusText}\n${errorText}`
`Perplexity API error: ${response.status} ${response.statusText}`
);
}

Expand Down Expand Up @@ -228,7 +239,9 @@ export async function performChatCompletion(
throw new Error("Invalid API response: missing or empty choices array");
}
}
throw new Error(`Failed to parse JSON response from Perplexity API: ${error}`);
// CWE-200: do not leak parser exception text (may include partial body).
logger.error("Failed to parse JSON response from Perplexity API", { error: String(error) });
throw new Error("Failed to parse JSON response from Perplexity API");
}

const firstChoice = data.choices[0];
Expand Down Expand Up @@ -292,7 +305,9 @@ export async function performSearch(
const json = await response.json();
data = SearchResponseSchema.parse(json);
} catch (error) {
throw new Error(`Failed to parse JSON response from Perplexity Search API: ${error}`);
// CWE-200: do not leak parser exception text (may include partial body).
logger.error("Failed to parse JSON response from Perplexity Search API", { error: String(error) });
throw new Error("Failed to parse JSON response from Perplexity Search API");
}

return formatSearchResults(data);
Expand Down