Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "1.7.1"
".": "1.7.2"
}
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 1.7.2 (2026-02-19)

Full Changelog: [v1.7.1...v1.7.2](https://github.com/CASParser/cas-parser-node/compare/v1.7.1...v1.7.2)

### Chores

* **internal/client:** fix form-urlencoded requests ([f39cb7f](https://github.com/CASParser/cas-parser-node/commit/f39cb7f6c2ad7c20da495ded91c644e432596425))
* **internal:** allow setting x-stainless-api-key header on mcp server requests ([5b3baf7](https://github.com/CASParser/cas-parser-node/commit/5b3baf7ead67ae4a2e371ceda87d5d9e97019c5d))
* **internal:** cache fetch instruction calls in MCP server ([335fe62](https://github.com/CASParser/cas-parser-node/commit/335fe623fd4ec8add20d330ab4e85db3165f8e81))

## 1.7.1 (2026-02-14)

Full Changelog: [v1.7.0...v1.7.1](https://github.com/CASParser/cas-parser-node/compare/v1.7.0...v1.7.1)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cas-parser-node",
"version": "1.7.1",
"version": "1.7.2",
"description": "The official TypeScript library for the Cas Parser API",
"author": "Cas Parser <sameer@casparser.in>",
"types": "dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cas-parser-node-mcp",
"version": "1.7.1",
"version": "1.7.2",
"description": "The official MCP Server for the Cas Parser API",
"author": "Cas Parser <sameer@casparser.in>",
"types": "dist/index.d.ts",
Expand Down
17 changes: 16 additions & 1 deletion packages/mcp-server/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,24 @@

import { IncomingMessage } from 'node:http';
import { ClientOptions } from 'cas-parser-node';
import { McpOptions } from './options';

export const parseAuthHeaders = (req: IncomingMessage, required?: boolean): Partial<ClientOptions> => {
export const parseClientAuthHeaders = (req: IncomingMessage, required?: boolean): Partial<ClientOptions> => {
const apiKey =
Array.isArray(req.headers['x-api-key']) ? req.headers['x-api-key'][0] : req.headers['x-api-key'];
return { apiKey };
};

export const getStainlessApiKey = (req: IncomingMessage, mcpOptions: McpOptions): string | undefined => {
// Try to get the key from the x-stainless-api-key header
const headerKey =
Array.isArray(req.headers['x-stainless-api-key']) ?
req.headers['x-stainless-api-key'][0]
: req.headers['x-stainless-api-key'];
if (headerKey && typeof headerKey === 'string') {
return headerKey;
}

// Fall back to value set in the mcpOptions (e.g. from environment variable), if provided
return mcpOptions.stainlessApiKey;
};
33 changes: 21 additions & 12 deletions packages/mcp-server/src/code-tool.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

import { McpTool, Metadata, ToolCallResult, asErrorResult, asTextContentResult } from './types';
import {
McpRequestContext,
McpTool,
Metadata,
ToolCallResult,
asErrorResult,
asTextContentResult,
} from './types';
import { Tool } from '@modelcontextprotocol/sdk/types.js';
import { readEnv, requireValue } from './util';
import { WorkerInput, WorkerOutput } from './code-tool-types';
import { SdkMethod } from './methods';
import { CasParser } from 'cas-parser-node';

const prompt = `Runs JavaScript code to interact with the Cas Parser API.

Expand Down Expand Up @@ -36,7 +42,7 @@ Variables will not persist between calls, so make sure to return or log any data
*
* @param endpoints - The endpoints to include in the list.
*/
export function codeTool(params: { blockedMethods: SdkMethod[] | undefined }): McpTool {
export function codeTool({ blockedMethods }: { blockedMethods: SdkMethod[] | undefined }): McpTool {
const metadata: Metadata = { resource: 'all', operation: 'write', tags: [] };
const tool: Tool = {
name: 'execute',
Expand All @@ -56,19 +62,24 @@ export function codeTool(params: { blockedMethods: SdkMethod[] | undefined }): M
required: ['code'],
},
};
const handler = async (client: CasParser, args: any): Promise<ToolCallResult> => {
const handler = async ({
reqContext,
args,
}: {
reqContext: McpRequestContext;
args: any;
}): Promise<ToolCallResult> => {
const code = args.code as string;
const intent = args.intent as string | undefined;
const client = reqContext.client;

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

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

// Setting a Stainless API key authenticates requests to the code tool endpoint.
const res = await fetch(codeModeEndpoint, {
method: 'POST',
headers: {
...(stainlessAPIKey && { Authorization: stainlessAPIKey }),
...(reqContext.stainlessApiKey && { Authorization: reqContext.stainlessApiKey }),
'Content-Type': 'application/json',
client_envs: JSON.stringify({
CAS_PARSER_API_KEY: requireValue(
Expand Down
15 changes: 9 additions & 6 deletions packages/mcp-server/src/docs-search-tool.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

import { Metadata, asTextContentResult } from './types';
import { readEnv } from './util';

import { Metadata, McpRequestContext, asTextContentResult } from './types';
import { Tool } from '@modelcontextprotocol/sdk/types.js';

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

export const handler = async (_: unknown, args: Record<string, unknown> | undefined) => {
export const handler = async ({
reqContext,
args,
}: {
reqContext: McpRequestContext;
args: Record<string, unknown> | undefined;
}) => {
const body = args as any;
const query = new URLSearchParams(body).toString();
const stainlessAPIKey = readEnv('STAINLESS_API_KEY');
const result = await fetch(`${docsSearchURL}?${query}`, {
headers: {
...(stainlessAPIKey && { Authorization: stainlessAPIKey }),
...(reqContext.stainlessApiKey && { Authorization: reqContext.stainlessApiKey }),
},
});

Expand Down
21 changes: 14 additions & 7 deletions packages/mcp-server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ClientOptions } from 'cas-parser-node';
import express from 'express';
import morgan from 'morgan';
import morganBody from 'morgan-body';
import { parseAuthHeaders } from './auth';
import { getStainlessApiKey, parseClientAuthHeaders } from './auth';
import { McpOptions } from './options';
import { initMcpServer, newMcpServer } from './server';

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

try {
const authOptions = parseAuthHeaders(req, false);
const authOptions = parseClientAuthHeaders(req, false);

await initMcpServer({
server: server,
mcpOptions: mcpOptions,
clientOptions: {
...clientOptions,
...authOptions,
},
stainlessApiKey: stainlessApiKey,
});
} catch (error) {
res.status(401).json({
Expand Down Expand Up @@ -112,20 +115,24 @@ export const streamableHTTPApp = ({
return app;
};

export const launchStreamableHTTPServer = async (params: {
export const launchStreamableHTTPServer = async ({
mcpOptions,
debug,
port,
}: {
mcpOptions: McpOptions;
debug: boolean;
port: number | string | undefined;
}) => {
const app = streamableHTTPApp({ mcpOptions: params.mcpOptions, debug: params.debug });
const server = app.listen(params.port);
const app = streamableHTTPApp({ mcpOptions, debug });
const server = app.listen(port);
const address = server.address();

if (typeof address === 'string') {
console.error(`MCP Server running on streamable HTTP at ${address}`);
} else if (address !== null) {
console.error(`MCP Server running on streamable HTTP on port ${address.port}`);
} else {
console.error(`MCP Server running on streamable HTTP on port ${params.port}`);
console.error(`MCP Server running on streamable HTTP on port ${port}`);
}
};
74 changes: 74 additions & 0 deletions packages/mcp-server/src/instructions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

import { readEnv } from './util';

const INSTRUCTIONS_CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes

interface InstructionsCacheEntry {
fetchedInstructions: string;
fetchedAt: number;
}

const instructionsCache = new Map<string, InstructionsCacheEntry>();

// Periodically evict stale entries so the cache doesn't grow unboundedly.
const _cacheCleanupInterval = setInterval(() => {
const now = Date.now();
for (const [key, entry] of instructionsCache) {
if (now - entry.fetchedAt > INSTRUCTIONS_CACHE_TTL_MS) {
instructionsCache.delete(key);
}
}
}, INSTRUCTIONS_CACHE_TTL_MS);

// Don't keep the process alive just for cleanup.
_cacheCleanupInterval.unref();

export async function getInstructions(stainlessApiKey: string | undefined): Promise<string> {
const cacheKey = stainlessApiKey ?? '';
const cached = instructionsCache.get(cacheKey);

if (cached && Date.now() - cached.fetchedAt <= INSTRUCTIONS_CACHE_TTL_MS) {
return cached.fetchedInstructions;
}

const fetchedInstructions = await fetchLatestInstructions(stainlessApiKey);
instructionsCache.set(cacheKey, { fetchedInstructions, fetchedAt: Date.now() });
return fetchedInstructions;
}

async function fetchLatestInstructions(stainlessApiKey: string | undefined): Promise<string> {
// Setting the stainless API key is optional, but may be required
// to authenticate requests to the Stainless API.
const response = await fetch(
readEnv('CODE_MODE_INSTRUCTIONS_URL') ?? 'https://api.stainless.com/api/ai/instructions/cas-parser',
{
method: 'GET',
headers: { ...(stainlessApiKey && { Authorization: stainlessApiKey }) },
},
);

let instructions: string | undefined;
if (!response.ok) {
console.warn(
'Warning: failed to retrieve MCP server instructions. Proceeding with default instructions...',
);

instructions = `
This is the cas-parser MCP server. You will use Code Mode to help the user perform
actions. You can use search_docs tool to learn about how to take action with this server. Then,
you will write TypeScript code using the execute tool take action. It is CRITICAL that you be
thoughtful and deliberate when executing code. Always try to entirely solve the problem in code
block: it can be as long as you need to get the job done!
`;
}

instructions ??= ((await response.json()) as { instructions: string }).instructions;
instructions = `
If needed, you can get the current time by executing Date.now().

${instructions}
`;

return instructions;
}
9 changes: 9 additions & 0 deletions packages/mcp-server/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import qs from 'qs';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import z from 'zod';
import { readEnv } from './util';

export type CLIOptions = McpOptions & {
debug: boolean;
Expand All @@ -14,6 +15,7 @@ export type CLIOptions = McpOptions & {

export type McpOptions = {
includeDocsTools?: boolean | undefined;
stainlessApiKey?: string | undefined;
codeAllowHttpGets?: boolean | undefined;
codeAllowedMethods?: string[] | undefined;
codeBlockedMethods?: string[] | undefined;
Expand Down Expand Up @@ -51,6 +53,12 @@ export function parseCLIOptions(): CLIOptions {
description: 'Port to serve on if using http transport',
})
.option('socket', { type: 'string', description: 'Unix socket to serve on if using http transport' })
.option('stainless-api-key', {
type: 'string',
default: readEnv('STAINLESS_API_KEY'),
description:
'API key for Stainless. Used to authenticate requests to Stainless-hosted tools endpoints.',
})
.option('tools', {
type: 'string',
array: true,
Expand Down Expand Up @@ -81,6 +89,7 @@ export function parseCLIOptions(): CLIOptions {
return {
...(includeDocsTools !== undefined && { includeDocsTools }),
debug: !!argv.debug,
stainlessApiKey: argv.stainlessApiKey,
codeAllowHttpGets: argv.codeAllowHttpGets,
codeAllowedMethods: argv.codeAllowedMethods,
codeBlockedMethods: argv.codeBlockedMethods,
Expand Down
Loading