Skip to content

Commit 9f7dd0b

Browse files
Merge pull request #126 from rbuchmayer-pplx/feat-api-key-provider
feat: per-call API key provider for multi-tenant embedders (v1.2.0)
2 parents 703edf7 + d19120b commit 9f7dd0b

9 files changed

Lines changed: 164 additions & 24 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
},
77
"metadata": {
88
"description": "Official Perplexity AI plugin providing real-time web search, reasoning, and research capabilities",
9-
"version": "1.1.0"
9+
"version": "1.2.0"
1010
},
1111
"plugins": [
1212
{
1313
"name": "perplexity",
1414
"source": "./",
1515
"description": "Real-time web search, reasoning, and research through Perplexity's API",
16-
"version": "1.1.0",
16+
"version": "1.2.0",
1717
"author": {
1818
"name": "Perplexity AI",
1919
"email": "api@perplexity.ai"

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,26 @@ npm install && npm run build && npm run start:http
147147

148148
The server will be accessible at `http://localhost:8080/mcp`
149149

150+
## Use as a Library
151+
152+
The package also exports the server factory for embedding in your own Node process:
153+
154+
```ts
155+
import { createPerplexityServer } from "@perplexity-ai/mcp-server";
156+
157+
// Single-tenant: reads PERPLEXITY_API_KEY from the environment.
158+
const server = createPerplexityServer("my-service");
159+
160+
// Multi-tenant hosts resolve the key per call instead. When a provider is
161+
// set, the environment variable is never consulted, and a provider that
162+
// returns no key fails the call rather than falling back.
163+
const tenantServer = createPerplexityServer("my-service", {
164+
apiKey: () => currentRequestApiKey,
165+
});
166+
```
167+
168+
Mount the returned server on any MCP transport (stdio, streamable HTTP, in-memory).
169+
150170
## Troubleshooting
151171

152172
- **API Key Issues**: Ensure `PERPLEXITY_API_KEY` is set correctly

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@perplexity-ai/mcp-server",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"mcpName": "ai.perplexity/mcp-server",
55
"description": "Real-time web search, reasoning, and research through Perplexity's API",
66
"keywords": [

server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
"name": "ai.perplexity/mcp-server",
44
"title": "Perplexity API Platform",
55
"description": "Real-time web search, reasoning, and research through Perplexity's API",
6-
"version": "1.1.0",
6+
"version": "1.2.0",
77
"packages": [
88
{
99
"registryType": "npm",
1010
"identifier": "@perplexity-ai/mcp-server",
11-
"version": "1.1.0",
11+
"version": "1.2.0",
1212
"transport": {
1313
"type": "stdio"
1414
}

src/index.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,41 @@ describe("Perplexity MCP Server", () => {
386386
});
387387
});
388388

389+
it("should use the provider key for the server-side cancel on timeout", async () => {
390+
process.env.PERPLEXITY_TIMEOUT_MS = "100";
391+
const authHeaders: string[] = [];
392+
393+
global.fetch = vi.fn().mockImplementation((url, options) => {
394+
authHeaders.push(
395+
(options?.headers as Record<string, string>)["Authorization"]
396+
);
397+
if (String(url).includes("/cancel")) {
398+
return Promise.resolve({ ok: true, json: async () => ({}) } as unknown as Response);
399+
}
400+
const signal = options?.signal as AbortSignal | undefined;
401+
const stream = new ReadableStream<Uint8Array>({
402+
start(controller) {
403+
controller.enqueue(
404+
encodeSse([{ type: "response.created", response: { id: "resp_stall" } }])
405+
);
406+
signal?.addEventListener("abort", () => {
407+
controller.error(
408+
new DOMException("The operation was aborted.", "AbortError")
409+
);
410+
});
411+
},
412+
});
413+
return Promise.resolve({ ok: true, body: stream } as unknown as Response);
414+
});
415+
416+
await expect(
417+
performAgentResponse(TEST_MESSAGES, "medium", undefined, undefined, undefined, () => "pplx-tenant-cancel")
418+
).rejects.toThrow("Request timeout");
419+
await vi.waitFor(() => expect(authHeaders).toHaveLength(2));
420+
// Both the original call and the fire-and-forget cancel carry the provider key.
421+
expect(authHeaders).toEqual(["Bearer pplx-tenant-cancel", "Bearer pplx-tenant-cancel"]);
422+
});
423+
389424
it("should return the answer when the stream stays open after completion", async () => {
390425
process.env.PERPLEXITY_TIMEOUT_MS = "100";
391426
const cancelCalls: string[] = [];

src/server.ts

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@ import type {
77
AgentSearchResult,
88
AgentToolOptions,
99
AgentCallHooks,
10+
ApiKeyProvider,
11+
PerplexityServerOptions,
1012
SearchResponse,
1113
UndiciRequestOptions
1214
} from "./types.js";
1315
import { AgentResponseSchema, SearchResponseSchema } from "./validation.js";
1416

17+
export type { ApiKeyProvider, PerplexityServerOptions } from "./types.js";
18+
1519
const PERPLEXITY_API_KEY = process.env.PERPLEXITY_API_KEY;
1620
const PERPLEXITY_BASE_URL = process.env.PERPLEXITY_BASE_URL || "https://api.perplexity.ai";
17-
const VERSION = "1.1.0";
21+
const VERSION = "1.2.0";
1822

1923
// Agent API presets backing each tool: https://docs.perplexity.ai/docs/agent-api/presets
2024
export const ASK_PRESET = "fast";
@@ -68,9 +72,21 @@ async function makeApiRequest(
6872
body: Record<string, unknown>,
6973
serviceOrigin: string | undefined,
7074
signal?: AbortSignal,
75+
apiKey?: ApiKeyProvider,
7176
): Promise<Response> {
72-
if (!PERPLEXITY_API_KEY) {
73-
throw new Error("PERPLEXITY_API_KEY environment variable is required");
77+
// A configured provider fully replaces the env var: falling back would let
78+
// a multi-tenant misconfiguration silently bill the process-wide key.
79+
let resolvedApiKey: string | undefined;
80+
if (apiKey) {
81+
resolvedApiKey = apiKey();
82+
if (!resolvedApiKey) {
83+
throw new Error("API key provider returned no key");
84+
}
85+
} else {
86+
resolvedApiKey = PERPLEXITY_API_KEY;
87+
if (!resolvedApiKey) {
88+
throw new Error("PERPLEXITY_API_KEY environment variable is required");
89+
}
7490
}
7591

7692
// Read timeout fresh each time to respect env var changes
@@ -92,7 +108,7 @@ async function makeApiRequest(
92108
try {
93109
const headers: Record<string, string> = {
94110
"Content-Type": "application/json",
95-
"Authorization": `Bearer ${PERPLEXITY_API_KEY}`,
111+
"Authorization": `Bearer ${resolvedApiKey}`,
96112
"User-Agent": `perplexity-mcp/${VERSION}`,
97113
"X-Source": "pplx-mcp-server",
98114
};
@@ -134,9 +150,9 @@ async function makeApiRequest(
134150
}
135151

136152
/** Best-effort cancellation of an agent run so an abandoned request stops billing. */
137-
export async function cancelAgentResponse(responseId: string, serviceOrigin?: string): Promise<void> {
153+
export async function cancelAgentResponse(responseId: string, serviceOrigin?: string, apiKey?: ApiKeyProvider): Promise<void> {
138154
try {
139-
await makeApiRequest(`v1/agent/${encodeURIComponent(responseId)}/cancel`, {}, serviceOrigin);
155+
await makeApiRequest(`v1/agent/${encodeURIComponent(responseId)}/cancel`, {}, serviceOrigin, undefined, apiKey);
140156
} catch {
141157
// The run may already be terminal; nothing actionable either way.
142158
}
@@ -151,6 +167,7 @@ export async function consumeAgentStream(
151167
hooks?: AgentCallHooks,
152168
serviceOrigin?: string,
153169
deadlineSignal?: AbortSignal,
170+
apiKey?: ApiKeyProvider,
154171
): Promise<AgentResponse> {
155172
const body = response.body;
156173
if (!body) {
@@ -265,7 +282,7 @@ export async function consumeAgentStream(
265282
if (hooks?.signal?.aborted || deadlineSignal?.aborted) {
266283
if (responseId) {
267284
// Stop the server-side run so an abandoned request stops billing.
268-
void cancelAgentResponse(responseId, serviceOrigin);
285+
void cancelAgentResponse(responseId, serviceOrigin, apiKey);
269286
}
270287
if (hooks?.signal?.aborted) {
271288
throw new Error("Request cancelled");
@@ -277,7 +294,7 @@ export async function consumeAgentStream(
277294

278295
if (hooks?.signal?.aborted) {
279296
if (responseId) {
280-
void cancelAgentResponse(responseId, serviceOrigin);
297+
void cancelAgentResponse(responseId, serviceOrigin, apiKey);
281298
}
282299
throw new Error("Request cancelled");
283300
}
@@ -385,7 +402,8 @@ export async function performAgentResponse(
385402
preset: string,
386403
serviceOrigin?: string,
387404
options?: AgentToolOptions,
388-
hooks?: AgentCallHooks
405+
hooks?: AgentCallHooks,
406+
apiKey?: ApiKeyProvider,
389407
): Promise<string> {
390408
const webSearchTool = buildWebSearchTool(options);
391409

@@ -418,8 +436,8 @@ export async function performAgentResponse(
418436
}
419437

420438
try {
421-
const response = await makeApiRequest("v1/agent", body, serviceOrigin, deadline.signal);
422-
const agentResponse = await consumeAgentStream(response, hooks, serviceOrigin, deadline.signal);
439+
const response = await makeApiRequest("v1/agent", body, serviceOrigin, deadline.signal, apiKey);
440+
const agentResponse = await consumeAgentStream(response, hooks, serviceOrigin, deadline.signal, apiKey);
423441
return formatAgentResponseText(agentResponse);
424442
} catch (error) {
425443
if (hooks?.signal?.aborted) {
@@ -463,7 +481,8 @@ export async function performSearch(
463481
maxTokensPerPage: number = 1024,
464482
country?: string,
465483
filters?: Pick<AgentToolOptions, "search_recency_filter" | "search_domain_filter">,
466-
serviceOrigin?: string
484+
serviceOrigin?: string,
485+
apiKey?: ApiKeyProvider,
467486
): Promise<string> {
468487
const body: Record<string, unknown> = {
469488
query: query,
@@ -474,7 +493,7 @@ export async function performSearch(
474493
...(filters?.search_domain_filter && { search_domain_filter: filters.search_domain_filter }),
475494
};
476495

477-
const response = await makeApiRequest("search", body, serviceOrigin);
496+
const response = await makeApiRequest("search", body, serviceOrigin, undefined, apiKey);
478497

479498
let data: SearchResponse;
480499
try {
@@ -518,7 +537,7 @@ function buildHooks(extra: ToolExtra | undefined): AgentCallHooks {
518537
};
519538
}
520539

521-
export function createPerplexityServer(serviceOrigin?: string) {
540+
export function createPerplexityServer(serviceOrigin?: string, serverOptions?: PerplexityServerOptions) {
522541
const server = new McpServer(
523542
{
524543
name: "ai.perplexity/mcp-server",
@@ -604,6 +623,7 @@ export function createPerplexityServer(serviceOrigin?: string) {
604623
serviceOrigin,
605624
Object.keys(options).length > 0 ? options : undefined,
606625
buildHooks(extra),
626+
serverOptions?.apiKey,
607627
);
608628
return {
609629
content: [{ type: "text" as const, text: result }],
@@ -640,6 +660,7 @@ export function createPerplexityServer(serviceOrigin?: string) {
640660
serviceOrigin,
641661
undefined,
642662
buildHooks(extra),
663+
serverOptions?.apiKey,
643664
);
644665
return {
645666
content: [{ type: "text" as const, text: result }],
@@ -686,6 +707,7 @@ export function createPerplexityServer(serviceOrigin?: string) {
686707
serviceOrigin,
687708
Object.keys(options).length > 0 ? options : undefined,
688709
buildHooks(extra),
710+
serverOptions?.apiKey,
689711
);
690712
return {
691713
content: [{ type: "text" as const, text: result }],
@@ -745,7 +767,7 @@ export function createPerplexityServer(serviceOrigin?: string) {
745767
...(search_domain_filter && { search_domain_filter }),
746768
};
747769

748-
const result = await performSearch(query, maxResults, maxTokensPerPage, countryCode, filters, serviceOrigin);
770+
const result = await performSearch(query, maxResults, maxTokensPerPage, countryCode, filters, serviceOrigin, serverOptions?.apiKey);
749771
return {
750772
content: [{ type: "text" as const, text: result }],
751773
structuredContent: { results: result },

src/transport.test.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
22
import { createPerplexityServer, ASK_PRESET, REASON_PRESET, RESEARCH_PRESET } from "./server.js";
3+
import type { PerplexityServerOptions } from "./types.js";
34
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
45
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
56
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
@@ -39,8 +40,8 @@ function agentSseResponse(text: string): Response {
3940
return { ok: true, body: stream } as unknown as Response;
4041
}
4142

42-
async function connectInMemoryClient() {
43-
const server = createPerplexityServer();
43+
async function connectInMemoryClient(serverOptions?: PerplexityServerOptions) {
44+
const server = createPerplexityServer(undefined, serverOptions);
4445
const client = new Client({ name: "test-client", version: "1.0.0" });
4546
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
4647
await Promise.all([
@@ -362,6 +363,51 @@ describe("Transport Integration Tests", () => {
362363
}
363364
});
364365

366+
it("should use the configured API key provider instead of the env key", async () => {
367+
process.env.PERPLEXITY_API_KEY = "pplx-env-key-must-not-be-used";
368+
global.fetch = vi.fn().mockResolvedValue(agentSseResponse("tenant answer"));
369+
370+
const { client, server } = await connectInMemoryClient({
371+
apiKey: () => "pplx-tenant-a",
372+
});
373+
try {
374+
const result: any = await client.callTool({
375+
name: "perplexity_ask",
376+
arguments: { messages: [{ role: "user", content: "test" }] },
377+
});
378+
379+
expect(result.isError).toBeFalsy();
380+
const headers = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1]
381+
.headers as Record<string, string>;
382+
expect(headers["Authorization"]).toBe("Bearer pplx-tenant-a");
383+
} finally {
384+
await client.close();
385+
await server.close();
386+
}
387+
});
388+
389+
it("should fail the call when the API key provider returns no key", async () => {
390+
process.env.PERPLEXITY_API_KEY = "pplx-env-key-must-not-be-used";
391+
global.fetch = vi.fn();
392+
393+
const { client, server } = await connectInMemoryClient({
394+
apiKey: () => undefined,
395+
});
396+
try {
397+
const result: any = await client.callTool({
398+
name: "perplexity_ask",
399+
arguments: { messages: [{ role: "user", content: "test" }] },
400+
});
401+
402+
expect(result.isError).toBe(true);
403+
expect(result.content[0].text).toContain("API key provider returned no key");
404+
expect(global.fetch).not.toHaveBeenCalled();
405+
} finally {
406+
await client.close();
407+
await server.close();
408+
}
409+
});
410+
365411
it("should forward search filters to the search API request body", async () => {
366412
global.fetch = vi.fn().mockResolvedValue({
367413
ok: true,

src/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,23 @@ export interface AgentProgressUpdate {
9696
message: string;
9797
}
9898

99+
/**
100+
* Resolves the API key for an upstream Perplexity API call. Invoked once per
101+
* request, so a closure over per-request state (e.g. an inbound Authorization
102+
* header) gives each embedded server instance its own key.
103+
*/
104+
export type ApiKeyProvider = () => string | undefined;
105+
106+
export interface PerplexityServerOptions {
107+
/**
108+
* Per-call API key resolution for embedders hosting the server for more
109+
* than one key (multi-tenant). When set, the PERPLEXITY_API_KEY environment
110+
* variable is never consulted: a provider that returns no key fails the
111+
* call rather than silently falling back to the process-wide key.
112+
*/
113+
apiKey?: ApiKeyProvider;
114+
}
115+
99116
export interface AgentCallHooks {
100117
/** Abort signal from the MCP request; triggers server-side cancellation. */
101118
signal?: AbortSignal;

0 commit comments

Comments
 (0)