Skip to content

Commit 5b3baf7

Browse files
chore(internal): allow setting x-stainless-api-key header on mcp server requests
1 parent f39cb7f commit 5b3baf7

8 files changed

Lines changed: 109 additions & 46 deletions

File tree

packages/mcp-server/src/auth.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,24 @@
22

33
import { IncomingMessage } from 'node:http';
44
import { ClientOptions } from 'cas-parser-node';
5+
import { McpOptions } from './options';
56

6-
export const parseAuthHeaders = (req: IncomingMessage, required?: boolean): Partial<ClientOptions> => {
7+
export const parseClientAuthHeaders = (req: IncomingMessage, required?: boolean): Partial<ClientOptions> => {
78
const apiKey =
89
Array.isArray(req.headers['x-api-key']) ? req.headers['x-api-key'][0] : req.headers['x-api-key'];
910
return { apiKey };
1011
};
12+
13+
export const getStainlessApiKey = (req: IncomingMessage, mcpOptions: McpOptions): string | undefined => {
14+
// Try to get the key from the x-stainless-api-key header
15+
const headerKey =
16+
Array.isArray(req.headers['x-stainless-api-key']) ?
17+
req.headers['x-stainless-api-key'][0]
18+
: req.headers['x-stainless-api-key'];
19+
if (headerKey && typeof headerKey === 'string') {
20+
return headerKey;
21+
}
22+
23+
// Fall back to value set in the mcpOptions (e.g. from environment variable), if provided
24+
return mcpOptions.stainlessApiKey;
25+
};

packages/mcp-server/src/code-tool.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
22

3-
import { McpTool, Metadata, ToolCallResult, asErrorResult, asTextContentResult } from './types';
3+
import {
4+
McpRequestContext,
5+
McpTool,
6+
Metadata,
7+
ToolCallResult,
8+
asErrorResult,
9+
asTextContentResult,
10+
} from './types';
411
import { Tool } from '@modelcontextprotocol/sdk/types.js';
512
import { readEnv, requireValue } from './util';
613
import { WorkerInput, WorkerOutput } from './code-tool-types';
714
import { SdkMethod } from './methods';
8-
import { CasParser } from 'cas-parser-node';
915

1016
const prompt = `Runs JavaScript code to interact with the Cas Parser API.
1117
@@ -36,7 +42,7 @@ Variables will not persist between calls, so make sure to return or log any data
3642
*
3743
* @param endpoints - The endpoints to include in the list.
3844
*/
39-
export function codeTool(params: { blockedMethods: SdkMethod[] | undefined }): McpTool {
45+
export function codeTool({ blockedMethods }: { blockedMethods: SdkMethod[] | undefined }): McpTool {
4046
const metadata: Metadata = { resource: 'all', operation: 'write', tags: [] };
4147
const tool: Tool = {
4248
name: 'execute',
@@ -56,19 +62,24 @@ export function codeTool(params: { blockedMethods: SdkMethod[] | undefined }): M
5662
required: ['code'],
5763
},
5864
};
59-
const handler = async (client: CasParser, args: any): Promise<ToolCallResult> => {
65+
const handler = async ({
66+
reqContext,
67+
args,
68+
}: {
69+
reqContext: McpRequestContext;
70+
args: any;
71+
}): Promise<ToolCallResult> => {
6072
const code = args.code as string;
6173
const intent = args.intent as string | undefined;
74+
const client = reqContext.client;
6275

6376
// Do very basic blocking of code that includes forbidden method names.
6477
//
6578
// WARNING: This is not secure against obfuscation and other evasion methods. If
6679
// stronger security blocks are required, then these should be enforced in the downstream
6780
// API (e.g., by having users call the MCP server with API keys with limited permissions).
68-
if (params.blockedMethods) {
69-
const blockedMatches = params.blockedMethods.filter((method) =>
70-
code.includes(method.fullyQualifiedName),
71-
);
81+
if (blockedMethods) {
82+
const blockedMatches = blockedMethods.filter((method) => code.includes(method.fullyQualifiedName));
7283
if (blockedMatches.length > 0) {
7384
return asErrorResult(
7485
`The following methods have been blocked by the MCP server and cannot be used in code execution: ${blockedMatches
@@ -78,16 +89,14 @@ export function codeTool(params: { blockedMethods: SdkMethod[] | undefined }): M
7889
}
7990
}
8091

81-
// this is not required, but passing a Stainless API key for the matching project_name
82-
// will allow you to run code-mode queries against non-published versions of your SDK.
83-
const stainlessAPIKey = readEnv('STAINLESS_API_KEY');
8492
const codeModeEndpoint =
8593
readEnv('CODE_MODE_ENDPOINT_URL') ?? 'https://api.stainless.com/api/ai/code-tool';
8694

95+
// Setting a Stainless API key authenticates requests to the code tool endpoint.
8796
const res = await fetch(codeModeEndpoint, {
8897
method: 'POST',
8998
headers: {
90-
...(stainlessAPIKey && { Authorization: stainlessAPIKey }),
99+
...(reqContext.stainlessApiKey && { Authorization: reqContext.stainlessApiKey }),
91100
'Content-Type': 'application/json',
92101
client_envs: JSON.stringify({
93102
CAS_PARSER_API_KEY: requireValue(

packages/mcp-server/src/docs-search-tool.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
22

3-
import { Metadata, asTextContentResult } from './types';
4-
import { readEnv } from './util';
5-
3+
import { Metadata, McpRequestContext, asTextContentResult } from './types';
64
import { Tool } from '@modelcontextprotocol/sdk/types.js';
75

86
export const metadata: Metadata = {
@@ -43,13 +41,18 @@ export const tool: Tool = {
4341
const docsSearchURL =
4442
process.env['DOCS_SEARCH_URL'] || 'https://api.stainless.com/api/projects/cas-parser/docs/search';
4543

46-
export const handler = async (_: unknown, args: Record<string, unknown> | undefined) => {
44+
export const handler = async ({
45+
reqContext,
46+
args,
47+
}: {
48+
reqContext: McpRequestContext;
49+
args: Record<string, unknown> | undefined;
50+
}) => {
4751
const body = args as any;
4852
const query = new URLSearchParams(body).toString();
49-
const stainlessAPIKey = readEnv('STAINLESS_API_KEY');
5053
const result = await fetch(`${docsSearchURL}?${query}`, {
5154
headers: {
52-
...(stainlessAPIKey && { Authorization: stainlessAPIKey }),
55+
...(reqContext.stainlessApiKey && { Authorization: reqContext.stainlessApiKey }),
5356
},
5457
});
5558

packages/mcp-server/src/http.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ClientOptions } from 'cas-parser-node';
66
import express from 'express';
77
import morgan from 'morgan';
88
import morganBody from 'morgan-body';
9-
import { parseAuthHeaders } from './auth';
9+
import { getStainlessApiKey, parseClientAuthHeaders } from './auth';
1010
import { McpOptions } from './options';
1111
import { initMcpServer, newMcpServer } from './server';
1212

@@ -21,17 +21,20 @@ const newServer = async ({
2121
req: express.Request;
2222
res: express.Response;
2323
}): Promise<McpServer | null> => {
24-
const server = await newMcpServer();
24+
const stainlessApiKey = getStainlessApiKey(req, mcpOptions);
25+
const server = await newMcpServer(stainlessApiKey);
2526

2627
try {
27-
const authOptions = parseAuthHeaders(req, false);
28+
const authOptions = parseClientAuthHeaders(req, false);
29+
2830
await initMcpServer({
2931
server: server,
3032
mcpOptions: mcpOptions,
3133
clientOptions: {
3234
...clientOptions,
3335
...authOptions,
3436
},
37+
stainlessApiKey: stainlessApiKey,
3538
});
3639
} catch (error) {
3740
res.status(401).json({
@@ -112,20 +115,24 @@ export const streamableHTTPApp = ({
112115
return app;
113116
};
114117

115-
export const launchStreamableHTTPServer = async (params: {
118+
export const launchStreamableHTTPServer = async ({
119+
mcpOptions,
120+
debug,
121+
port,
122+
}: {
116123
mcpOptions: McpOptions;
117124
debug: boolean;
118125
port: number | string | undefined;
119126
}) => {
120-
const app = streamableHTTPApp({ mcpOptions: params.mcpOptions, debug: params.debug });
121-
const server = app.listen(params.port);
127+
const app = streamableHTTPApp({ mcpOptions, debug });
128+
const server = app.listen(port);
122129
const address = server.address();
123130

124131
if (typeof address === 'string') {
125132
console.error(`MCP Server running on streamable HTTP at ${address}`);
126133
} else if (address !== null) {
127134
console.error(`MCP Server running on streamable HTTP on port ${address.port}`);
128135
} else {
129-
console.error(`MCP Server running on streamable HTTP on port ${params.port}`);
136+
console.error(`MCP Server running on streamable HTTP on port ${port}`);
130137
}
131138
};

packages/mcp-server/src/options.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import qs from 'qs';
44
import yargs from 'yargs';
55
import { hideBin } from 'yargs/helpers';
66
import z from 'zod';
7+
import { readEnv } from './util';
78

89
export type CLIOptions = McpOptions & {
910
debug: boolean;
@@ -14,6 +15,7 @@ export type CLIOptions = McpOptions & {
1415

1516
export type McpOptions = {
1617
includeDocsTools?: boolean | undefined;
18+
stainlessApiKey?: string | undefined;
1719
codeAllowHttpGets?: boolean | undefined;
1820
codeAllowedMethods?: string[] | undefined;
1921
codeBlockedMethods?: string[] | undefined;
@@ -51,6 +53,12 @@ export function parseCLIOptions(): CLIOptions {
5153
description: 'Port to serve on if using http transport',
5254
})
5355
.option('socket', { type: 'string', description: 'Unix socket to serve on if using http transport' })
56+
.option('stainless-api-key', {
57+
type: 'string',
58+
default: readEnv('STAINLESS_API_KEY'),
59+
description:
60+
'API key for Stainless. Used to authenticate requests to Stainless-hosted tools endpoints.',
61+
})
5462
.option('tools', {
5563
type: 'string',
5664
array: true,
@@ -81,6 +89,7 @@ export function parseCLIOptions(): CLIOptions {
8189
return {
8290
...(includeDocsTools !== undefined && { includeDocsTools }),
8391
debug: !!argv.debug,
92+
stainlessApiKey: argv.stainlessApiKey,
8493
codeAllowHttpGets: argv.codeAllowHttpGets,
8594
codeAllowedMethods: argv.codeAllowedMethods,
8695
codeBlockedMethods: argv.codeBlockedMethods,

packages/mcp-server/src/server.ts

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,17 @@ import { codeTool } from './code-tool';
1313
import docsSearchTool from './docs-search-tool';
1414
import { McpOptions } from './options';
1515
import { blockedMethodsForCodeTool } from './methods';
16-
import { HandlerFunction, McpTool } from './types';
16+
import { HandlerFunction, McpRequestContext, ToolCallResult, McpTool } from './types';
1717
import { readEnv } from './util';
1818

19-
async function getInstructions() {
20-
// This API key is optional; providing it allows the server to fetch instructions for unreleased versions.
21-
const stainlessAPIKey = readEnv('STAINLESS_API_KEY');
19+
async function getInstructions(stainlessApiKey: string | undefined): Promise<string> {
20+
// Setting the stainless API key is optional, but may be required
21+
// to authenticate requests to the Stainless API.
2222
const response = await fetch(
2323
readEnv('CODE_MODE_INSTRUCTIONS_URL') ?? 'https://api.stainless.com/api/ai/instructions/cas-parser',
2424
{
2525
method: 'GET',
26-
headers: { ...(stainlessAPIKey && { Authorization: stainlessAPIKey }) },
26+
headers: { ...(stainlessApiKey && { Authorization: stainlessApiKey }) },
2727
},
2828
);
2929

@@ -52,14 +52,14 @@ async function getInstructions() {
5252
return instructions;
5353
}
5454

55-
export const newMcpServer = async () =>
55+
export const newMcpServer = async (stainlessApiKey: string | undefined) =>
5656
new McpServer(
5757
{
5858
name: 'cas_parser_node_api',
5959
version: '1.7.1',
6060
},
6161
{
62-
instructions: await getInstructions(),
62+
instructions: await getInstructions(stainlessApiKey),
6363
capabilities: { tools: {}, logging: {} },
6464
},
6565
);
@@ -72,6 +72,7 @@ export async function initMcpServer(params: {
7272
server: Server | McpServer;
7373
clientOptions?: ClientOptions;
7474
mcpOptions?: McpOptions;
75+
stainlessApiKey?: string | undefined;
7576
}) {
7677
const server = params.server instanceof McpServer ? params.server.server : params.server;
7778

@@ -116,7 +117,14 @@ export async function initMcpServer(params: {
116117
throw new Error(`Unknown tool: ${name}`);
117118
}
118119

119-
return executeHandler(mcpTool.handler, client, args);
120+
return executeHandler({
121+
handler: mcpTool.handler,
122+
reqContext: {
123+
client,
124+
stainlessApiKey: params.stainlessApiKey ?? params.mcpOptions?.stainlessApiKey,
125+
},
126+
args,
127+
});
120128
});
121129

122130
server.setRequestHandler(SetLevelRequestSchema, async (request) => {
@@ -161,10 +169,14 @@ export function selectTools(options?: McpOptions): McpTool[] {
161169
/**
162170
* Runs the provided handler with the given client and arguments.
163171
*/
164-
export async function executeHandler(
165-
handler: HandlerFunction,
166-
client: CasParser,
167-
args: Record<string, unknown> | undefined,
168-
) {
169-
return await handler(client, args || {});
172+
export async function executeHandler({
173+
handler,
174+
reqContext,
175+
args,
176+
}: {
177+
handler: HandlerFunction;
178+
reqContext: McpRequestContext;
179+
args: Record<string, unknown> | undefined;
180+
}): Promise<ToolCallResult> {
181+
return await handler({ reqContext, args: args || {} });
170182
}

packages/mcp-server/src/stdio.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import { McpOptions } from './options';
33
import { initMcpServer, newMcpServer } from './server';
44

55
export const launchStdioServer = async (mcpOptions: McpOptions) => {
6-
const server = await newMcpServer();
6+
const server = await newMcpServer(mcpOptions.stainlessApiKey);
77

8-
await initMcpServer({ server, mcpOptions });
8+
await initMcpServer({ server, mcpOptions, stainlessApiKey: mcpOptions.stainlessApiKey });
99

1010
const transport = new StdioServerTransport();
1111
await server.connect(transport);

packages/mcp-server/src/types.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,18 @@ export type ToolCallResult = {
4242
isError?: boolean;
4343
};
4444

45-
export type HandlerFunction = (
46-
client: CasParser,
47-
args: Record<string, unknown> | undefined,
48-
) => Promise<ToolCallResult>;
45+
export type McpRequestContext = {
46+
client: CasParser;
47+
stainlessApiKey?: string | undefined;
48+
};
49+
50+
export type HandlerFunction = ({
51+
reqContext,
52+
args,
53+
}: {
54+
reqContext: McpRequestContext;
55+
args: Record<string, unknown> | undefined;
56+
}) => Promise<ToolCallResult>;
4957

5058
export function asTextContentResult(result: unknown): ToolCallResult {
5159
return {

0 commit comments

Comments
 (0)