Skip to content

Commit 72fb78b

Browse files
feat(mcp): forward client metadata
Co-Authored-By: Miguel Brandão <miguel@exa.ai>
1 parent 96c7853 commit 72fb78b

6 files changed

Lines changed: 411 additions & 10 deletions

File tree

api/mcp.ts

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ import { initializeMcpServer } from '../src/mcp-handler.js';
66
import { Ratelimit } from '@upstash/ratelimit';
77
import { Redis } from '@upstash/redis';
88
import { isJwtToken, verifyOAuthToken } from '../src/utils/auth.js';
9+
import {
10+
buildMcpClientMetadata,
11+
extractInitializeClientInfo,
12+
MCP_CLIENT_SESSION_TTL_SECONDS,
13+
sanitizeMcpClientMetadata,
14+
type McpClientMetadata,
15+
} from '../src/utils/mcpClientMetadata.js';
916

1017
// Origin: '*' is safe — auth is per-request via headers/query, never cookies.
1118
const CORS_HEADERS: Record<string, string> = {
@@ -50,6 +57,59 @@ let dailyLimiter: Ratelimit | null = null;
5057
let rateLimitersInitialized = false;
5158
let redisClient: Redis | null = null;
5259

60+
function getMcpClientSessionKey(sessionId: string): string {
61+
return `exa-mcp:client:${sessionId}`;
62+
}
63+
64+
async function saveMcpClientMetadata(sessionId: string | undefined, metadata: McpClientMetadata | undefined, debug: boolean): Promise<void> {
65+
if (!sessionId || !metadata?.clientInfo) {
66+
return;
67+
}
68+
69+
initializeRateLimiters();
70+
71+
if (!redisClient) {
72+
return;
73+
}
74+
75+
try {
76+
await redisClient.set(getMcpClientSessionKey(sessionId), JSON.stringify(metadata), {
77+
ex: MCP_CLIENT_SESSION_TTL_SECONDS,
78+
});
79+
} catch (error) {
80+
if (debug) {
81+
console.error('[EXA-MCP] Failed to save MCP client metadata:', error);
82+
}
83+
}
84+
}
85+
86+
async function loadMcpClientMetadata(sessionId: string | undefined, debug: boolean): Promise<McpClientMetadata | undefined> {
87+
if (!sessionId) {
88+
return undefined;
89+
}
90+
91+
initializeRateLimiters();
92+
93+
if (!redisClient) {
94+
return undefined;
95+
}
96+
97+
try {
98+
const value = await redisClient.get<string>(getMcpClientSessionKey(sessionId));
99+
if (typeof value !== 'string') {
100+
return undefined;
101+
}
102+
103+
const parsed: unknown = JSON.parse(value);
104+
return sanitizeMcpClientMetadata(parsed);
105+
} catch (error) {
106+
if (debug) {
107+
console.error('[EXA-MCP] Failed to load MCP client metadata:', error);
108+
}
109+
return undefined;
110+
}
111+
}
112+
53113
function initializeRateLimiters(): boolean {
54114
if (rateLimitersInitialized) {
55115
return qpsLimiter !== null;
@@ -299,6 +359,7 @@ interface RequestConfig {
299359
authMethod: 'oauth' | 'api_key' | 'free_tier';
300360
exaSource?: string;
301361
mcpSessionId?: string;
362+
mcpClient?: McpClientMetadata;
302363
defaultSearchType?: 'auto' | 'fast';
303364
/** True when a Bearer token was a JWT but failed OAuth verification (expired, bad sig, wrong issuer/audience). */
304365
invalidOAuthJwt: boolean;
@@ -417,7 +478,7 @@ async function getConfigFromRequest(request: Request): Promise<RequestConfig> {
417478
* configuration (tools and API key). This prevents API key leakage between
418479
* different users who might pass different keys via URL.
419480
*/
420-
function createHandler(config: { exaApiKey?: string; enabledTools?: string[]; debug: boolean; userProvidedApiKey: boolean; exaSource?: string; mcpSessionId?: string; defaultSearchType?: 'auto' | 'fast' }) {
481+
function createHandler(config: { exaApiKey?: string; enabledTools?: string[]; debug: boolean; userProvidedApiKey: boolean; exaSource?: string; mcpSessionId?: string; mcpClient?: McpClientMetadata; defaultSearchType?: 'auto' | 'fast' }) {
421482
return createMcpHandler(
422483
(server: any) => {
423484
initializeMcpServer(server, config);
@@ -484,8 +545,9 @@ async function handleRequest(request: Request, options?: { forceOAuth?: boolean
484545
*/
485546
async function processRequest(request: Request, options?: { forceOAuth?: boolean }): Promise<Response> {
486547
const debug = process.env.DEBUG === 'true';
487-
const isInitializeRequest =
488-
request.method === 'POST' ? isInitializeMethod(await request.clone().text()) : false;
548+
const body = request.method === 'POST' ? await request.clone().text() : undefined;
549+
const isInitializeRequest = isInitializeMethod(body ?? '');
550+
const initializeClientInfo = extractInitializeClientInfo(body);
489551

490552
// Check user-agent bypass BEFORE the 401 gate so bypass clients never see auth prompts
491553
const userAgent = request.headers.get('user-agent') || '';
@@ -518,6 +580,15 @@ async function processRequest(request: Request, options?: { forceOAuth?: boolean
518580
return create401Response();
519581
}
520582

583+
const storedMcpClient = isInitializeRequest ? undefined : await loadMcpClientMetadata(config.mcpSessionId, config.debug);
584+
config.mcpClient = buildMcpClientMetadata({
585+
source: config.exaSource,
586+
sessionId: config.mcpSessionId,
587+
clientInfo: initializeClientInfo,
588+
stored: storedMcpClient,
589+
userAgent,
590+
});
591+
521592
if (config.debug) {
522593
console.log(`[EXA-MCP] Request URL: ${request.url}`);
523594
console.log(`[EXA-MCP] Enabled tools: ${config.enabledTools?.join(', ') || 'default'}`);
@@ -536,12 +607,8 @@ async function processRequest(request: Request, options?: { forceOAuth?: boolean
536607
// Rate limit users who didn't provide their own API key (including bypass users)
537608
// Only rate limit actual tool calls (tools/call), not protocol methods like tools/list
538609
if (!config.userProvidedApiKey && request.method === 'POST') {
539-
// Clone the request to read the body without consuming it
540-
const clonedRequest = request.clone();
541-
const body = await clonedRequest.text();
542-
543610
// Only rate limit actual tool calls, not protocol methods
544-
if (isRateLimitedMethod(body)) {
611+
if (isRateLimitedMethod(body ?? '')) {
545612
// Initialize rate limiters on first request (lazy init)
546613
initializeRateLimiters();
547614

@@ -591,9 +658,22 @@ async function processRequest(request: Request, options?: { forceOAuth?: boolean
591658

592659
const response = withCors(await handler(request));
593660

594-
if (isInitializeRequest && response.ok && !response.headers.has('Mcp-Session-Id')) {
661+
if (isInitializeRequest && response.ok) {
662+
const responseSessionId = response.headers.get('Mcp-Session-Id') ?? config.mcpSessionId ?? randomUUID();
663+
const metadata = buildMcpClientMetadata({
664+
source: config.exaSource,
665+
sessionId: responseSessionId,
666+
clientInfo: initializeClientInfo,
667+
userAgent,
668+
});
669+
await saveMcpClientMetadata(responseSessionId, metadata, config.debug);
670+
671+
if (response.headers.has('Mcp-Session-Id')) {
672+
return response;
673+
}
674+
595675
const headers = new Headers(response.headers);
596-
headers.set('Mcp-Session-Id', randomUUID());
676+
headers.set('Mcp-Session-Id', responseSessionId);
597677
return new Response(response.body, {
598678
status: response.status,
599679
statusText: response.statusText,

src/mcp-handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface McpConfig {
3636
userProvidedApiKey?: boolean;
3737
exaSource?: string;
3838
mcpSessionId?: string;
39+
mcpClient?: unknown;
3940
defaultSearchType?: 'auto' | 'fast';
4041
}
4142

src/tools/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import { serializeMcpClientMetadata } from '../utils/mcpClientMetadata.js';
2+
13
// Build Exa reporting headers, appending x-exa-source if present
24
export function integrationHeaders(tool: string, config?: Record<string, unknown>) {
35
const source = config?.exaSource;
46
const mcpSessionId = config?.mcpSessionId;
7+
const mcpClient = serializeMcpClientMetadata(config?.mcpClient);
58
const headers: Record<string, string> = {
69
'x-exa-integration': typeof source === 'string' ? `${tool}:${source}` : tool,
710
};
@@ -10,6 +13,10 @@ export function integrationHeaders(tool: string, config?: Record<string, unknown
1013
headers['x-exa-mcp-session-id'] = mcpSessionId;
1114
}
1215

16+
if (mcpClient) {
17+
headers['x-exa-mcp-client'] = mcpClient;
18+
}
19+
1320
return headers;
1421
}
1522

src/utils/mcpClientMetadata.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
export const MCP_CLIENT_SESSION_TTL_SECONDS = 24 * 60 * 60;
2+
3+
const MCP_CLIENT_FIELD_MAX_LENGTH = 256;
4+
const MCP_CLIENT_USER_AGENT_MAX_LENGTH = 512;
5+
const MCP_CLIENT_HEADER_MAX_LENGTH = 2048;
6+
const UNKNOWN_MCP_CLIENT_NAME = 'unknown';
7+
8+
export interface McpClientInfo {
9+
name?: string;
10+
title?: string;
11+
version?: string;
12+
}
13+
14+
export interface McpClientMetadata {
15+
source?: string;
16+
sessionId?: string;
17+
clientInfo?: McpClientInfo;
18+
userAgent?: string;
19+
}
20+
21+
function unknownMcpClientInfo(): McpClientInfo {
22+
return { name: UNKNOWN_MCP_CLIENT_NAME };
23+
}
24+
25+
function sanitizeMcpClientField(value: unknown, maxLength = MCP_CLIENT_FIELD_MAX_LENGTH): string | undefined {
26+
if (typeof value !== 'string') {
27+
return undefined;
28+
}
29+
30+
let withoutControlCharacters = '';
31+
for (const character of value) {
32+
const codePoint = character.charCodeAt(0);
33+
withoutControlCharacters += codePoint <= 31 || codePoint === 127 ? ' ' : character;
34+
}
35+
36+
const sanitized = withoutControlCharacters.trim();
37+
if (!sanitized) {
38+
return undefined;
39+
}
40+
41+
return sanitized.slice(0, maxLength);
42+
}
43+
44+
function compactMcpClientMetadata(metadata: McpClientMetadata): McpClientMetadata | undefined {
45+
const compact: McpClientMetadata = {};
46+
47+
if (metadata.source) compact.source = metadata.source;
48+
if (metadata.sessionId) compact.sessionId = metadata.sessionId;
49+
if (metadata.clientInfo && Object.keys(metadata.clientInfo).length > 0) {
50+
compact.clientInfo = metadata.clientInfo;
51+
}
52+
if (metadata.userAgent) compact.userAgent = metadata.userAgent;
53+
54+
return Object.keys(compact).length > 0 ? compact : undefined;
55+
}
56+
57+
function isMcpClientRecord(value: unknown): value is Record<string, unknown> {
58+
return !!value && typeof value === 'object' && !Array.isArray(value);
59+
}
60+
61+
function sanitizeMcpClientInfo(clientInfo: unknown): McpClientInfo | undefined {
62+
if (!isMcpClientRecord(clientInfo)) {
63+
return undefined;
64+
}
65+
66+
const compact: McpClientInfo = {};
67+
const name = sanitizeMcpClientField(clientInfo.name);
68+
const title = sanitizeMcpClientField(clientInfo.title);
69+
const version = sanitizeMcpClientField(clientInfo.version);
70+
71+
if (name) compact.name = name;
72+
if (title) compact.title = title;
73+
if (version) compact.version = version;
74+
75+
return Object.keys(compact).length > 0 ? compact : undefined;
76+
}
77+
78+
export function extractInitializeClientInfo(body: string | undefined): McpClientInfo | undefined {
79+
if (!body) {
80+
return undefined;
81+
}
82+
83+
try {
84+
const parsed: unknown = JSON.parse(body);
85+
if (!isMcpClientRecord(parsed) || parsed.method !== 'initialize') {
86+
return undefined;
87+
}
88+
89+
if (!isMcpClientRecord(parsed.params)) {
90+
return unknownMcpClientInfo();
91+
}
92+
93+
return sanitizeMcpClientInfo(parsed.params.clientInfo) ?? unknownMcpClientInfo();
94+
} catch {
95+
return unknownMcpClientInfo();
96+
}
97+
}
98+
99+
export function sanitizeMcpClientMetadata(metadata: unknown): McpClientMetadata | undefined {
100+
if (!isMcpClientRecord(metadata)) {
101+
return undefined;
102+
}
103+
104+
return compactMcpClientMetadata({
105+
source: sanitizeMcpClientField(metadata.source),
106+
sessionId: sanitizeMcpClientField(metadata.sessionId),
107+
clientInfo: sanitizeMcpClientInfo(metadata.clientInfo),
108+
userAgent: sanitizeMcpClientField(metadata.userAgent, MCP_CLIENT_USER_AGENT_MAX_LENGTH),
109+
});
110+
}
111+
112+
export function buildMcpClientMetadata(input: {
113+
source?: string;
114+
sessionId?: string;
115+
clientInfo?: McpClientInfo;
116+
stored?: McpClientMetadata;
117+
userAgent?: string;
118+
}): McpClientMetadata {
119+
try {
120+
const metadata = compactMcpClientMetadata({
121+
source: sanitizeMcpClientField(input.source ?? input.stored?.source),
122+
sessionId: sanitizeMcpClientField(input.sessionId ?? input.stored?.sessionId),
123+
clientInfo: sanitizeMcpClientInfo(input.clientInfo ?? input.stored?.clientInfo),
124+
userAgent: sanitizeMcpClientField(input.userAgent ?? input.stored?.userAgent, MCP_CLIENT_USER_AGENT_MAX_LENGTH),
125+
}) ?? { clientInfo: unknownMcpClientInfo() };
126+
127+
if (!metadata.clientInfo && !metadata.userAgent) {
128+
metadata.clientInfo = unknownMcpClientInfo();
129+
}
130+
131+
return metadata;
132+
} catch {
133+
return { clientInfo: unknownMcpClientInfo() };
134+
}
135+
}
136+
137+
export function serializeMcpClientMetadata(value: unknown): string | undefined {
138+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
139+
return undefined;
140+
}
141+
142+
try {
143+
const serialized = JSON.stringify(value);
144+
if (serialized === '{}' || serialized.length > MCP_CLIENT_HEADER_MAX_LENGTH) {
145+
return undefined;
146+
}
147+
148+
return serialized;
149+
} catch {
150+
return JSON.stringify({ clientInfo: unknownMcpClientInfo() });
151+
}
152+
}

0 commit comments

Comments
 (0)