@perplexity-ai/mcp-server 0.2.3 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +46 -21
- package/dist/index.test.js +487 -0
- package/dist/vitest.config.js +19 -0
- package/package.json +13 -8
package/dist/index.js
CHANGED
|
@@ -155,9 +155,31 @@ if (!PERPLEXITY_API_KEY) {
|
|
|
155
155
|
console.error("Error: PERPLEXITY_API_KEY environment variable is required");
|
|
156
156
|
process.exit(1);
|
|
157
157
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Validates an array of message objects for chat completion tools.
|
|
160
|
+
* Ensures each message has a valid role and content field.
|
|
161
|
+
*
|
|
162
|
+
* @param {any} messages - The messages to validate
|
|
163
|
+
* @param {string} toolName - The name of the tool calling this validation (for error messages)
|
|
164
|
+
* @throws {Error} If messages is not an array or if any message is invalid
|
|
165
|
+
*/
|
|
166
|
+
function validateMessages(messages, toolName) {
|
|
167
|
+
if (!Array.isArray(messages)) {
|
|
168
|
+
throw new Error(`Invalid arguments for ${toolName}: 'messages' must be an array`);
|
|
169
|
+
}
|
|
170
|
+
for (let i = 0; i < messages.length; i++) {
|
|
171
|
+
const msg = messages[i];
|
|
172
|
+
if (!msg || typeof msg !== 'object') {
|
|
173
|
+
throw new Error(`Invalid message at index ${i}: must be an object`);
|
|
174
|
+
}
|
|
175
|
+
if (!msg.role || typeof msg.role !== 'string') {
|
|
176
|
+
throw new Error(`Invalid message at index ${i}: 'role' must be a string`);
|
|
177
|
+
}
|
|
178
|
+
if (msg.content === undefined || msg.content === null || typeof msg.content !== 'string') {
|
|
179
|
+
throw new Error(`Invalid message at index ${i}: 'content' must be a string`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
161
183
|
/**
|
|
162
184
|
* Performs a chat completion by sending a request to the Perplexity API.
|
|
163
185
|
* Appends citations to the returned message content if they exist.
|
|
@@ -167,15 +189,17 @@ const TIMEOUT_MS = parseInt(process.env.PERPLEXITY_TIMEOUT_MS || "300000", 10);
|
|
|
167
189
|
* @returns {Promise<string>} The chat completion result with appended citations.
|
|
168
190
|
* @throws Will throw an error if the API request fails.
|
|
169
191
|
*/
|
|
170
|
-
function performChatCompletion(messages_1) {
|
|
192
|
+
export function performChatCompletion(messages_1) {
|
|
171
193
|
return __awaiter(this, arguments, void 0, function* (messages, model = "sonar-pro") {
|
|
194
|
+
// Read timeout fresh each time to respect env var changes
|
|
195
|
+
const TIMEOUT_MS = parseInt(process.env.PERPLEXITY_TIMEOUT_MS || "300000", 10);
|
|
172
196
|
// Construct the API endpoint URL and request body
|
|
173
197
|
const url = new URL("https://api.perplexity.ai/chat/completions");
|
|
174
198
|
const body = {
|
|
175
199
|
model: model, // Model identifier passed as parameter
|
|
176
200
|
messages: messages,
|
|
177
201
|
// Additional parameters can be added here if required (e.g., max_tokens, temperature, etc.)
|
|
178
|
-
// See the Sonar API documentation for more details:
|
|
202
|
+
// See the Sonar API documentation for more details:
|
|
179
203
|
// https://docs.perplexity.ai/api-reference/chat-completions
|
|
180
204
|
};
|
|
181
205
|
const controller = new AbortController();
|
|
@@ -219,8 +243,16 @@ function performChatCompletion(messages_1) {
|
|
|
219
243
|
catch (jsonError) {
|
|
220
244
|
throw new Error(`Failed to parse JSON response from Perplexity API: ${jsonError}`);
|
|
221
245
|
}
|
|
222
|
-
//
|
|
223
|
-
|
|
246
|
+
// Validate response structure
|
|
247
|
+
if (!data.choices || !Array.isArray(data.choices) || data.choices.length === 0) {
|
|
248
|
+
throw new Error("Invalid API response: missing or empty choices array");
|
|
249
|
+
}
|
|
250
|
+
const firstChoice = data.choices[0];
|
|
251
|
+
if (!firstChoice.message || typeof firstChoice.message.content !== 'string') {
|
|
252
|
+
throw new Error("Invalid API response: missing message content");
|
|
253
|
+
}
|
|
254
|
+
// Directly retrieve the main message content from the response
|
|
255
|
+
let messageContent = firstChoice.message.content;
|
|
224
256
|
// If citations are provided, append them to the message content
|
|
225
257
|
if (data.citations && Array.isArray(data.citations) && data.citations.length > 0) {
|
|
226
258
|
messageContent += "\n\nCitations:\n";
|
|
@@ -237,7 +269,7 @@ function performChatCompletion(messages_1) {
|
|
|
237
269
|
* @param {any} data - The search response data from the API.
|
|
238
270
|
* @returns {string} Formatted search results.
|
|
239
271
|
*/
|
|
240
|
-
function formatSearchResults(data) {
|
|
272
|
+
export function formatSearchResults(data) {
|
|
241
273
|
if (!data.results || !Array.isArray(data.results)) {
|
|
242
274
|
return "No search results found.";
|
|
243
275
|
}
|
|
@@ -265,8 +297,10 @@ function formatSearchResults(data) {
|
|
|
265
297
|
* @returns {Promise<string>} The formatted search results.
|
|
266
298
|
* @throws Will throw an error if the API request fails.
|
|
267
299
|
*/
|
|
268
|
-
function performSearch(query_1) {
|
|
300
|
+
export function performSearch(query_1) {
|
|
269
301
|
return __awaiter(this, arguments, void 0, function* (query, maxResults = 10, maxTokensPerPage = 1024, country) {
|
|
302
|
+
// Read timeout fresh each time to respect env var changes
|
|
303
|
+
const TIMEOUT_MS = parseInt(process.env.PERPLEXITY_TIMEOUT_MS || "300000", 10);
|
|
270
304
|
const url = new URL("https://api.perplexity.ai/search");
|
|
271
305
|
const body = {
|
|
272
306
|
query: query,
|
|
@@ -352,10 +386,7 @@ server.setRequestHandler(CallToolRequestSchema, (request) => __awaiter(void 0, v
|
|
|
352
386
|
}
|
|
353
387
|
switch (name) {
|
|
354
388
|
case "perplexity_ask": {
|
|
355
|
-
|
|
356
|
-
throw new Error("Invalid arguments for perplexity_ask: 'messages' must be an array");
|
|
357
|
-
}
|
|
358
|
-
// Invoke the chat completion function with the provided messages
|
|
389
|
+
validateMessages(args.messages, "perplexity_ask");
|
|
359
390
|
const messages = args.messages;
|
|
360
391
|
const result = yield performChatCompletion(messages, "sonar-pro");
|
|
361
392
|
return {
|
|
@@ -364,10 +395,7 @@ server.setRequestHandler(CallToolRequestSchema, (request) => __awaiter(void 0, v
|
|
|
364
395
|
};
|
|
365
396
|
}
|
|
366
397
|
case "perplexity_research": {
|
|
367
|
-
|
|
368
|
-
throw new Error("Invalid arguments for perplexity_research: 'messages' must be an array");
|
|
369
|
-
}
|
|
370
|
-
// Invoke the chat completion function with the provided messages using the deep research model
|
|
398
|
+
validateMessages(args.messages, "perplexity_research");
|
|
371
399
|
const messages = args.messages;
|
|
372
400
|
const result = yield performChatCompletion(messages, "sonar-deep-research");
|
|
373
401
|
return {
|
|
@@ -376,10 +404,7 @@ server.setRequestHandler(CallToolRequestSchema, (request) => __awaiter(void 0, v
|
|
|
376
404
|
};
|
|
377
405
|
}
|
|
378
406
|
case "perplexity_reason": {
|
|
379
|
-
|
|
380
|
-
throw new Error("Invalid arguments for perplexity_reason: 'messages' must be an array");
|
|
381
|
-
}
|
|
382
|
-
// Invoke the chat completion function with the provided messages using the reasoning model
|
|
407
|
+
validateMessages(args.messages, "perplexity_reason");
|
|
383
408
|
const messages = args.messages;
|
|
384
409
|
const result = yield performChatCompletion(messages, "sonar-reasoning-pro");
|
|
385
410
|
return {
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import { formatSearchResults, performChatCompletion, performSearch } from "./index.js";
|
|
12
|
+
describe("Perplexity MCP Server", () => {
|
|
13
|
+
let originalFetch;
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
originalFetch = global.fetch;
|
|
16
|
+
});
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
global.fetch = originalFetch;
|
|
19
|
+
vi.restoreAllMocks();
|
|
20
|
+
});
|
|
21
|
+
describe("formatSearchResults", () => {
|
|
22
|
+
it("should format search results correctly", () => {
|
|
23
|
+
const mockData = {
|
|
24
|
+
results: [
|
|
25
|
+
{
|
|
26
|
+
title: "Test Result 1",
|
|
27
|
+
url: "https://example.com/1",
|
|
28
|
+
snippet: "This is a test snippet",
|
|
29
|
+
date: "2025-01-01",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
title: "Test Result 2",
|
|
33
|
+
url: "https://example.com/2",
|
|
34
|
+
snippet: "Another snippet",
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
const formatted = formatSearchResults(mockData);
|
|
39
|
+
expect(formatted).toContain("Found 2 search results");
|
|
40
|
+
expect(formatted).toContain("Test Result 1");
|
|
41
|
+
expect(formatted).toContain("https://example.com/1");
|
|
42
|
+
expect(formatted).toContain("This is a test snippet");
|
|
43
|
+
expect(formatted).toContain("Date: 2025-01-01");
|
|
44
|
+
expect(formatted).toContain("Test Result 2");
|
|
45
|
+
});
|
|
46
|
+
it("should handle empty results", () => {
|
|
47
|
+
const mockData = { results: [] };
|
|
48
|
+
const formatted = formatSearchResults(mockData);
|
|
49
|
+
expect(formatted).toContain("Found 0 search results");
|
|
50
|
+
});
|
|
51
|
+
it("should handle missing results array", () => {
|
|
52
|
+
const mockData = {};
|
|
53
|
+
const formatted = formatSearchResults(mockData);
|
|
54
|
+
expect(formatted).toBe("No search results found.");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe("performChatCompletion", () => {
|
|
58
|
+
it("should successfully complete chat request", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
59
|
+
const mockResponse = {
|
|
60
|
+
choices: [
|
|
61
|
+
{
|
|
62
|
+
message: {
|
|
63
|
+
content: "This is a test response",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
69
|
+
ok: true,
|
|
70
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return mockResponse; }),
|
|
71
|
+
});
|
|
72
|
+
const messages = [{ role: "user", content: "test question" }];
|
|
73
|
+
const result = yield performChatCompletion(messages, "sonar-pro");
|
|
74
|
+
expect(result).toBe("This is a test response");
|
|
75
|
+
expect(global.fetch).toHaveBeenCalledWith("https://api.perplexity.ai/chat/completions", expect.objectContaining({
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: {
|
|
78
|
+
"Content-Type": "application/json",
|
|
79
|
+
Authorization: "Bearer test-api-key",
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
model: "sonar-pro",
|
|
83
|
+
messages,
|
|
84
|
+
}),
|
|
85
|
+
}));
|
|
86
|
+
}));
|
|
87
|
+
it("should append citations when present", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
88
|
+
const mockResponse = {
|
|
89
|
+
choices: [
|
|
90
|
+
{
|
|
91
|
+
message: {
|
|
92
|
+
content: "Response with citations",
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
citations: [
|
|
97
|
+
"https://example.com/source1",
|
|
98
|
+
"https://example.com/source2",
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
102
|
+
ok: true,
|
|
103
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return mockResponse; }),
|
|
104
|
+
});
|
|
105
|
+
const messages = [{ role: "user", content: "test" }];
|
|
106
|
+
const result = yield performChatCompletion(messages);
|
|
107
|
+
expect(result).toContain("Response with citations");
|
|
108
|
+
expect(result).toContain("\n\nCitations:\n");
|
|
109
|
+
expect(result).toContain("[1] https://example.com/source1");
|
|
110
|
+
expect(result).toContain("[2] https://example.com/source2");
|
|
111
|
+
}));
|
|
112
|
+
it("should handle API errors", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
113
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
114
|
+
ok: false,
|
|
115
|
+
status: 401,
|
|
116
|
+
statusText: "Unauthorized",
|
|
117
|
+
text: () => __awaiter(void 0, void 0, void 0, function* () { return "Invalid API key"; }),
|
|
118
|
+
});
|
|
119
|
+
const messages = [{ role: "user", content: "test" }];
|
|
120
|
+
yield expect(performChatCompletion(messages)).rejects.toThrow("Perplexity API error: 401 Unauthorized");
|
|
121
|
+
}));
|
|
122
|
+
it("should handle timeout errors", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
123
|
+
process.env.PERPLEXITY_TIMEOUT_MS = "100";
|
|
124
|
+
global.fetch = vi.fn().mockImplementation((_url, options) => {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const signal = options === null || options === void 0 ? void 0 : options.signal;
|
|
127
|
+
if (signal) {
|
|
128
|
+
signal.addEventListener("abort", () => {
|
|
129
|
+
reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
resolve({
|
|
134
|
+
ok: true,
|
|
135
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return ({ choices: [{ message: { content: "late" } }] }); }),
|
|
136
|
+
});
|
|
137
|
+
}, 200);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
const messages = [{ role: "user", content: "test" }];
|
|
141
|
+
yield expect(performChatCompletion(messages)).rejects.toThrow("Request timeout");
|
|
142
|
+
}));
|
|
143
|
+
it("should handle network errors", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
144
|
+
global.fetch = vi.fn().mockRejectedValue(new Error("Network failure"));
|
|
145
|
+
const messages = [{ role: "user", content: "test" }];
|
|
146
|
+
yield expect(performChatCompletion(messages)).rejects.toThrow("Network error while calling Perplexity API");
|
|
147
|
+
}));
|
|
148
|
+
});
|
|
149
|
+
describe("performSearch", () => {
|
|
150
|
+
it("should successfully perform search", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
151
|
+
const mockResponse = {
|
|
152
|
+
results: [
|
|
153
|
+
{
|
|
154
|
+
title: "Search Result",
|
|
155
|
+
url: "https://example.com",
|
|
156
|
+
snippet: "Test snippet",
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
161
|
+
ok: true,
|
|
162
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return mockResponse; }),
|
|
163
|
+
});
|
|
164
|
+
const result = yield performSearch("test query", 10, 1024);
|
|
165
|
+
expect(result).toContain("Found 1 search results");
|
|
166
|
+
expect(result).toContain("Search Result");
|
|
167
|
+
expect(global.fetch).toHaveBeenCalledWith("https://api.perplexity.ai/search", expect.objectContaining({
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: {
|
|
170
|
+
"Content-Type": "application/json",
|
|
171
|
+
Authorization: "Bearer test-api-key",
|
|
172
|
+
},
|
|
173
|
+
body: JSON.stringify({
|
|
174
|
+
query: "test query",
|
|
175
|
+
max_results: 10,
|
|
176
|
+
max_tokens_per_page: 1024,
|
|
177
|
+
}),
|
|
178
|
+
}));
|
|
179
|
+
}));
|
|
180
|
+
it("should include country parameter when provided", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
181
|
+
const mockResponse = { results: [] };
|
|
182
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
183
|
+
ok: true,
|
|
184
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return mockResponse; }),
|
|
185
|
+
});
|
|
186
|
+
yield performSearch("test", 10, 1024, "US");
|
|
187
|
+
expect(global.fetch).toHaveBeenCalledWith("https://api.perplexity.ai/search", expect.objectContaining({
|
|
188
|
+
body: JSON.stringify({
|
|
189
|
+
query: "test",
|
|
190
|
+
max_results: 10,
|
|
191
|
+
max_tokens_per_page: 1024,
|
|
192
|
+
country: "US",
|
|
193
|
+
}),
|
|
194
|
+
}));
|
|
195
|
+
}));
|
|
196
|
+
it("should handle search API errors", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
197
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
198
|
+
ok: false,
|
|
199
|
+
status: 500,
|
|
200
|
+
statusText: "Internal Server Error",
|
|
201
|
+
text: () => __awaiter(void 0, void 0, void 0, function* () { return "Server error"; }),
|
|
202
|
+
});
|
|
203
|
+
yield expect(performSearch("test")).rejects.toThrow("Perplexity Search API error: 500 Internal Server Error");
|
|
204
|
+
}));
|
|
205
|
+
});
|
|
206
|
+
describe("API Response Validation", () => {
|
|
207
|
+
it("should handle empty choices array", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
208
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
209
|
+
ok: true,
|
|
210
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return ({ choices: [] }); }),
|
|
211
|
+
});
|
|
212
|
+
const messages = [{ role: "user", content: "test" }];
|
|
213
|
+
yield expect(performChatCompletion(messages)).rejects.toThrow("missing or empty choices array");
|
|
214
|
+
}));
|
|
215
|
+
it("should handle missing message content", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
216
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
217
|
+
ok: true,
|
|
218
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return ({ choices: [{ message: null }] }); }),
|
|
219
|
+
});
|
|
220
|
+
const messages = [{ role: "user", content: "test" }];
|
|
221
|
+
yield expect(performChatCompletion(messages)).rejects.toThrow("missing message content");
|
|
222
|
+
}));
|
|
223
|
+
it("should handle missing choices property", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
224
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
225
|
+
ok: true,
|
|
226
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return ({}); }),
|
|
227
|
+
});
|
|
228
|
+
const messages = [{ role: "user", content: "test" }];
|
|
229
|
+
yield expect(performChatCompletion(messages)).rejects.toThrow("missing or empty choices array");
|
|
230
|
+
}));
|
|
231
|
+
it("should handle malformed message object", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
232
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
233
|
+
ok: true,
|
|
234
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return ({ choices: [{ message: { content: 123 } }] }); }),
|
|
235
|
+
});
|
|
236
|
+
const messages = [{ role: "user", content: "test" }];
|
|
237
|
+
yield expect(performChatCompletion(messages)).rejects.toThrow("missing message content");
|
|
238
|
+
}));
|
|
239
|
+
it("should handle null choices", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
240
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
241
|
+
ok: true,
|
|
242
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return ({ choices: null }); }),
|
|
243
|
+
});
|
|
244
|
+
const messages = [{ role: "user", content: "test" }];
|
|
245
|
+
yield expect(performChatCompletion(messages)).rejects.toThrow("missing or empty choices array");
|
|
246
|
+
}));
|
|
247
|
+
it("should handle undefined message in choice", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
248
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
249
|
+
ok: true,
|
|
250
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return ({ choices: [{}] }); }),
|
|
251
|
+
});
|
|
252
|
+
const messages = [{ role: "user", content: "test" }];
|
|
253
|
+
yield expect(performChatCompletion(messages)).rejects.toThrow("missing message content");
|
|
254
|
+
}));
|
|
255
|
+
it("should handle empty citations array gracefully", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
256
|
+
const mockResponse = {
|
|
257
|
+
choices: [{ message: { content: "Response" } }],
|
|
258
|
+
citations: [],
|
|
259
|
+
};
|
|
260
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
261
|
+
ok: true,
|
|
262
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return mockResponse; }),
|
|
263
|
+
});
|
|
264
|
+
const messages = [{ role: "user", content: "test" }];
|
|
265
|
+
const result = yield performChatCompletion(messages);
|
|
266
|
+
expect(result).toBe("Response");
|
|
267
|
+
expect(result).not.toContain("Citations:");
|
|
268
|
+
}));
|
|
269
|
+
it("should handle non-array citations", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
270
|
+
const mockResponse = {
|
|
271
|
+
choices: [{ message: { content: "Response" } }],
|
|
272
|
+
citations: "not-an-array",
|
|
273
|
+
};
|
|
274
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
275
|
+
ok: true,
|
|
276
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return mockResponse; }),
|
|
277
|
+
});
|
|
278
|
+
const messages = [{ role: "user", content: "test" }];
|
|
279
|
+
const result = yield performChatCompletion(messages);
|
|
280
|
+
expect(result).toBe("Response");
|
|
281
|
+
expect(result).not.toContain("Citations:");
|
|
282
|
+
}));
|
|
283
|
+
});
|
|
284
|
+
describe("Edge Cases", () => {
|
|
285
|
+
it("should handle JSON parse errors gracefully", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
286
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
287
|
+
ok: true,
|
|
288
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
289
|
+
throw new Error("Invalid JSON");
|
|
290
|
+
}),
|
|
291
|
+
});
|
|
292
|
+
const messages = [{ role: "user", content: "test" }];
|
|
293
|
+
yield expect(performChatCompletion(messages)).rejects.toThrow("Failed to parse JSON response");
|
|
294
|
+
}));
|
|
295
|
+
it("should handle error text parse failures", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
296
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
297
|
+
ok: false,
|
|
298
|
+
status: 500,
|
|
299
|
+
statusText: "Internal Server Error",
|
|
300
|
+
text: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
301
|
+
throw new Error("Cannot read error");
|
|
302
|
+
}),
|
|
303
|
+
});
|
|
304
|
+
const messages = [{ role: "user", content: "test" }];
|
|
305
|
+
yield expect(performChatCompletion(messages)).rejects.toThrow("Unable to parse error response");
|
|
306
|
+
}));
|
|
307
|
+
it("should handle special characters in messages", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
308
|
+
const mockResponse = {
|
|
309
|
+
choices: [{ message: { content: "Response with émojis 🎉 and unicode ñ" } }],
|
|
310
|
+
};
|
|
311
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
312
|
+
ok: true,
|
|
313
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return mockResponse; }),
|
|
314
|
+
});
|
|
315
|
+
const messages = [{ role: "user", content: "test with émojis 🎉" }];
|
|
316
|
+
const result = yield performChatCompletion(messages);
|
|
317
|
+
expect(result).toContain("émojis 🎉");
|
|
318
|
+
expect(result).toContain("unicode ñ");
|
|
319
|
+
}));
|
|
320
|
+
it("should handle very long content strings", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
321
|
+
const longContent = "x".repeat(100000);
|
|
322
|
+
const mockResponse = {
|
|
323
|
+
choices: [{ message: { content: longContent } }],
|
|
324
|
+
};
|
|
325
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
326
|
+
ok: true,
|
|
327
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return mockResponse; }),
|
|
328
|
+
});
|
|
329
|
+
const messages = [{ role: "user", content: "test" }];
|
|
330
|
+
const result = yield performChatCompletion(messages);
|
|
331
|
+
expect(result).toBe(longContent);
|
|
332
|
+
expect(result.length).toBe(100000);
|
|
333
|
+
}));
|
|
334
|
+
it("should handle multiple models correctly", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
335
|
+
const models = ["sonar-pro", "sonar-deep-research", "sonar-reasoning-pro"];
|
|
336
|
+
for (const model of models) {
|
|
337
|
+
const mockResponse = {
|
|
338
|
+
choices: [{ message: { content: `Response from ${model}` } }],
|
|
339
|
+
};
|
|
340
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
341
|
+
ok: true,
|
|
342
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return mockResponse; }),
|
|
343
|
+
});
|
|
344
|
+
const messages = [{ role: "user", content: "test" }];
|
|
345
|
+
const result = yield performChatCompletion(messages, model);
|
|
346
|
+
expect(result).toContain(model);
|
|
347
|
+
expect(global.fetch).toHaveBeenCalledWith("https://api.perplexity.ai/chat/completions", expect.objectContaining({
|
|
348
|
+
body: expect.stringContaining(`"model":"${model}"`),
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
}));
|
|
352
|
+
it("should handle search with boundary values", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
353
|
+
const mockResponse = { results: [] };
|
|
354
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
355
|
+
ok: true,
|
|
356
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return mockResponse; }),
|
|
357
|
+
});
|
|
358
|
+
// Test max values
|
|
359
|
+
yield performSearch("test", 20, 2048);
|
|
360
|
+
expect(global.fetch).toHaveBeenCalledWith("https://api.perplexity.ai/search", expect.objectContaining({
|
|
361
|
+
body: expect.stringContaining('"max_results":20'),
|
|
362
|
+
}));
|
|
363
|
+
// Test min values
|
|
364
|
+
yield performSearch("test", 1, 256);
|
|
365
|
+
expect(global.fetch).toHaveBeenCalledWith("https://api.perplexity.ai/search", expect.objectContaining({
|
|
366
|
+
body: expect.stringContaining('"max_results":1'),
|
|
367
|
+
}));
|
|
368
|
+
}));
|
|
369
|
+
it("should handle formatSearchResults with missing optional fields", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
370
|
+
const mockData = {
|
|
371
|
+
results: [
|
|
372
|
+
{ title: "Test", url: "https://example.com" },
|
|
373
|
+
{ title: "Test 2", url: "https://example.com/2", snippet: "snippet only" },
|
|
374
|
+
{ title: "Test 3", url: "https://example.com/3", date: "2025-01-01" },
|
|
375
|
+
],
|
|
376
|
+
};
|
|
377
|
+
const formatted = formatSearchResults(mockData);
|
|
378
|
+
expect(formatted).toContain("Test");
|
|
379
|
+
expect(formatted).toContain("Test 2");
|
|
380
|
+
expect(formatted).toContain("snippet only");
|
|
381
|
+
expect(formatted).toContain("Date: 2025-01-01");
|
|
382
|
+
expect(formatted).not.toContain("undefined");
|
|
383
|
+
}));
|
|
384
|
+
it("should handle concurrent requests correctly", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
385
|
+
let callCount = 0;
|
|
386
|
+
global.fetch = vi.fn().mockImplementation(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
387
|
+
const currentCall = ++callCount;
|
|
388
|
+
yield new Promise((resolve) => setTimeout(resolve, 10));
|
|
389
|
+
return {
|
|
390
|
+
ok: true,
|
|
391
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
392
|
+
return ({
|
|
393
|
+
choices: [{ message: { content: `Response ${currentCall}` } }]
|
|
394
|
+
});
|
|
395
|
+
}),
|
|
396
|
+
};
|
|
397
|
+
}));
|
|
398
|
+
const messages = [{ role: "user", content: "test" }];
|
|
399
|
+
const promises = [
|
|
400
|
+
performChatCompletion(messages),
|
|
401
|
+
performChatCompletion(messages),
|
|
402
|
+
performChatCompletion(messages),
|
|
403
|
+
];
|
|
404
|
+
const results = yield Promise.all(promises);
|
|
405
|
+
expect(results).toHaveLength(3);
|
|
406
|
+
expect(global.fetch).toHaveBeenCalledTimes(3);
|
|
407
|
+
// Results should all be present (may not be unique due to timing)
|
|
408
|
+
expect(results.every(r => r.startsWith("Response"))).toBe(true);
|
|
409
|
+
}));
|
|
410
|
+
it("should respect timeout on each call independently", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
411
|
+
// First call with long timeout
|
|
412
|
+
process.env.PERPLEXITY_TIMEOUT_MS = "1000";
|
|
413
|
+
global.fetch = vi.fn().mockImplementation((_url, options) => {
|
|
414
|
+
return new Promise((resolve) => {
|
|
415
|
+
const signal = options === null || options === void 0 ? void 0 : options.signal;
|
|
416
|
+
setTimeout(() => {
|
|
417
|
+
if (!(signal === null || signal === void 0 ? void 0 : signal.aborted)) {
|
|
418
|
+
resolve({
|
|
419
|
+
ok: true,
|
|
420
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return ({ choices: [{ message: { content: "fast" } }] }); }),
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}, 50);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
const messages = [{ role: "user", content: "test" }];
|
|
427
|
+
const result1 = yield performChatCompletion(messages);
|
|
428
|
+
expect(result1).toBe("fast");
|
|
429
|
+
// Second call with short timeout
|
|
430
|
+
process.env.PERPLEXITY_TIMEOUT_MS = "10";
|
|
431
|
+
global.fetch = vi.fn().mockImplementation((_url, options) => {
|
|
432
|
+
return new Promise((resolve, reject) => {
|
|
433
|
+
const signal = options === null || options === void 0 ? void 0 : options.signal;
|
|
434
|
+
if (signal) {
|
|
435
|
+
signal.addEventListener("abort", () => {
|
|
436
|
+
reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
setTimeout(() => {
|
|
440
|
+
resolve({
|
|
441
|
+
ok: true,
|
|
442
|
+
json: () => __awaiter(void 0, void 0, void 0, function* () { return ({ choices: [{ message: { content: "slow" } }] }); }),
|
|
443
|
+
});
|
|
444
|
+
}, 100);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
yield expect(performChatCompletion(messages)).rejects.toThrow("timeout");
|
|
448
|
+
}));
|
|
449
|
+
});
|
|
450
|
+
describe("formatSearchResults Edge Cases", () => {
|
|
451
|
+
it("should handle results with null/undefined values", () => {
|
|
452
|
+
const mockData = {
|
|
453
|
+
results: [
|
|
454
|
+
{ title: null, url: "https://example.com", snippet: undefined },
|
|
455
|
+
{ title: "Valid", url: null, snippet: "snippet", date: undefined },
|
|
456
|
+
],
|
|
457
|
+
};
|
|
458
|
+
const formatted = formatSearchResults(mockData);
|
|
459
|
+
expect(formatted).toContain("null");
|
|
460
|
+
expect(formatted).toContain("Valid");
|
|
461
|
+
expect(formatted).not.toContain("undefined");
|
|
462
|
+
});
|
|
463
|
+
it("should handle empty strings in result fields", () => {
|
|
464
|
+
const mockData = {
|
|
465
|
+
results: [{ title: "", url: "", snippet: "", date: "" }],
|
|
466
|
+
};
|
|
467
|
+
const formatted = formatSearchResults(mockData);
|
|
468
|
+
expect(formatted).toContain("Found 1 search results");
|
|
469
|
+
});
|
|
470
|
+
it("should handle results with extra unexpected fields", () => {
|
|
471
|
+
const mockData = {
|
|
472
|
+
results: [
|
|
473
|
+
{
|
|
474
|
+
title: "Test",
|
|
475
|
+
url: "https://example.com",
|
|
476
|
+
unexpectedField: "should be ignored",
|
|
477
|
+
anotherField: 12345,
|
|
478
|
+
},
|
|
479
|
+
],
|
|
480
|
+
};
|
|
481
|
+
const formatted = formatSearchResults(mockData);
|
|
482
|
+
expect(formatted).toContain("Test");
|
|
483
|
+
expect(formatted).not.toContain("unexpectedField");
|
|
484
|
+
expect(formatted).not.toContain("12345");
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
export default defineConfig({
|
|
3
|
+
test: {
|
|
4
|
+
exclude: ['**/node_modules/**', '**/dist/**'],
|
|
5
|
+
env: {
|
|
6
|
+
PERPLEXITY_API_KEY: 'test-api-key',
|
|
7
|
+
},
|
|
8
|
+
coverage: {
|
|
9
|
+
provider: 'v8',
|
|
10
|
+
reporter: ['text', 'html', 'lcov'],
|
|
11
|
+
exclude: [
|
|
12
|
+
'**/node_modules/**',
|
|
13
|
+
'**/dist/**',
|
|
14
|
+
'**/*.test.ts',
|
|
15
|
+
'**/*.config.ts',
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@perplexity-ai/mcp-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"mcpName": "io.github.perplexityai/mcp-server",
|
|
5
|
+
"description": "Real-time web search, reasoning, and research through Perplexity's API",
|
|
5
6
|
"keywords": [
|
|
6
7
|
"ai",
|
|
7
8
|
"perplexity",
|
|
@@ -33,17 +34,21 @@
|
|
|
33
34
|
"scripts": {
|
|
34
35
|
"build": "tsc && shx chmod +x dist/*.js",
|
|
35
36
|
"prepare": "npm run build",
|
|
36
|
-
"watch": "tsc --watch"
|
|
37
|
+
"watch": "tsc --watch",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:watch": "vitest",
|
|
40
|
+
"test:coverage": "vitest run --coverage"
|
|
37
41
|
},
|
|
38
42
|
"dependencies": {
|
|
39
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
40
|
-
"
|
|
41
|
-
"dotenv": "^16.3.1"
|
|
43
|
+
"@modelcontextprotocol/sdk": "^1.21.1",
|
|
44
|
+
"dotenv": "^16.6.1"
|
|
42
45
|
},
|
|
43
46
|
"devDependencies": {
|
|
44
47
|
"@types/node": "^20",
|
|
45
|
-
"
|
|
46
|
-
"
|
|
48
|
+
"@vitest/coverage-v8": "^4.0.5",
|
|
49
|
+
"shx": "^0.4.0",
|
|
50
|
+
"typescript": "^5.9.3",
|
|
51
|
+
"vitest": "^4.0.5"
|
|
47
52
|
},
|
|
48
53
|
"engines": {
|
|
49
54
|
"node": ">=18"
|