@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 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
- // Configure timeout for API requests (default: 5 minutes)
159
- // Can be overridden via PERPLEXITY_TIMEOUT_MS environment variable
160
- const TIMEOUT_MS = parseInt(process.env.PERPLEXITY_TIMEOUT_MS || "300000", 10);
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
- // Directly retrieve the main message content from the response
223
- let messageContent = data.choices[0].message.content;
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
- if (!Array.isArray(args.messages)) {
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
- if (!Array.isArray(args.messages)) {
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
- if (!Array.isArray(args.messages)) {
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.2.3",
4
- "description": "Official MCP server for Perplexity API Platform",
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.0.1",
40
- "axios": "^1.6.2",
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
- "shx": "^0.3.4",
46
- "typescript": "^5.6.2"
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"