Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/floppy-chicken-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@transcend-io/mcp-server-base": patch
---

Adds user agent and toolcall headers mcp tool calls
5 changes: 5 additions & 0 deletions .changeset/loose-ravens-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@transcend-io/mcp-server-base": patch
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are these files used for?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They're generated when you run pnpm changeset and get combined to generate the change set in the deploy PR that publishes to npm.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repo has a really nifty DX outlined here.

---

Adds x-transcend-mcp-caller to allowed headers
5 changes: 5 additions & 0 deletions packages/mcp/mcp-server-base/src/clients/graphql/base.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { getRequestAuth } from '../../auth-context.js';
import { type AuthCredentials, authHeaders } from '../../auth.js';
import { ToolError, ErrorCode, classifyHttpError } from '../../errors.js';
import { getToolCallIdHeader } from '../../tool-call-context.js';
import type { RequestOptions } from '../../types/transcend.js';
import { TRANSCEND_MCP_USER_AGENT } from '../mcp-user-agent.js';

/**
* Structurally identical to the `Logger` interface in `@transcend-io/utils`,
Expand Down Expand Up @@ -142,12 +144,15 @@ export class TranscendGraphQLBase {
);
}

const toolCallId = getToolCallIdHeader();
const response = await fetch(url, {
method: 'POST',
headers: {
...authHeaders(effectiveAuth),
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': TRANSCEND_MCP_USER_AGENT,
...(toolCallId && { 'x-toolcall-id': toolCallId }),
},
body: JSON.stringify({ query, variables: variables || {} }),
signal: controller.signal,
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp/mcp-server-base/src/clients/mcp-user-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** User-Agent sent on outbound Transcend API requests from MCP tool handlers. */
export const TRANSCEND_MCP_USER_AGENT = 'Transcend-mcp';
8 changes: 8 additions & 0 deletions packages/mcp/mcp-server-base/src/clients/rest-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getRequestAuth } from '../auth-context.js';
import { type AuthCredentials, authHeaders } from '../auth.js';
import { getToolCallIdHeader } from '../tool-call-context.js';
import type {
DSRSubmission,
DSRResponse,
Expand All @@ -17,6 +18,7 @@ import type {
RequestOptions,
} from '../types/transcend.js';
import { SimpleLogger, type Logger } from './graphql/base.js';
import { TRANSCEND_MCP_USER_AGENT } from './mcp-user-agent.js';

export class TranscendRestClient {
private auth: AuthCredentials | null;
Expand Down Expand Up @@ -66,12 +68,15 @@ export class TranscendRestClient {
...fetchOptions
} = options;

const toolCallId = getToolCallIdHeader();
const headers: Record<string, string> = {
...authHeaders(effectiveAuth),
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Transcend-Version': '2021-11-15',
...((options.headers as Record<string, string>) || {}),
'User-Agent': TRANSCEND_MCP_USER_AGENT,
...(toolCallId && { 'x-toolcall-id': toolCallId }),
};

const controller = new AbortController();
Expand Down Expand Up @@ -191,10 +196,13 @@ export class TranscendRestClient {
throw new Error('No authentication configured. Provide an API key or session cookie.');
}
const url = `${this.baseUrl}/v1/files?key=${encodeURIComponent(downloadKey)}`;
const toolCallId = getToolCallIdHeader();
const response = await fetch(url, {
headers: {
...authHeaders(effectiveAuth),
Accept: 'application/octet-stream',
'User-Agent': TRANSCEND_MCP_USER_AGENT,
...(toolCallId && { 'x-toolcall-id': toolCallId }),
},
});
if (!response.ok) {
Expand Down
3 changes: 3 additions & 0 deletions packages/mcp/mcp-server-base/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ export type { AuthCredentials, ApiKeyAuth, SessionCookieAuth } from './auth.js';

export { requestAuthContext, getRequestAuth } from './auth-context.js';

export { toolCallContext, getToolCallIdHeader } from './tool-call-context.js';
export type { ToolCallContext } from './tool-call-context.js';

export { TranscendGraphQLBase, SimpleLogger } from './clients/graphql/base.js';
export type { Logger, ListOptions } from './clients/graphql/base.js';
export { TranscendRestClient } from './clients/rest-client.js';
Expand Down
8 changes: 7 additions & 1 deletion packages/mcp/mcp-server-base/src/server/build-server.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { randomUUID } from 'node:crypto';

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { toJsonSchemaCompat } from '@modelcontextprotocol/sdk/server/zod-json-schema-compat.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';

import { SimpleLogger } from '../clients/graphql/base.js';
import { toolCallContext } from '../tool-call-context.js';
import { createErrorResult, createToolResult } from '../tools/helpers.js';
import type { ToolDefinition } from '../tools/types.js';

Expand Down Expand Up @@ -81,7 +84,10 @@ export function buildMcpServer(options: BuildMcpServerOptions): Server {
};
}

const result = await tool.handler(parseResult.data);
const result = await toolCallContext.run(
{ toolName: name, correlationId: randomUUID() },
() => tool.handler(parseResult.data),
);
logger.debug(`Tool ${name} completed successfully`);

return {
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/mcp-server-base/src/server/run-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export async function runMcpHttp(
'Authorization',
'X-Transcend-Api-Key',
'x-transcend-active-organization-id',
'x-transcend-mcp-caller',
'Last-Event-ID',
],
exposedHeaders: ['mcp-session-id'],
Expand Down
23 changes: 23 additions & 0 deletions packages/mcp/mcp-server-base/src/tool-call-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { AsyncLocalStorage } from 'node:async_hooks';

/**
* Correlates all outbound Transcend HTTP requests made during a single MCP
* `tools/call` invocation (same UUID across every fetch in that handler).
*/
export interface ToolCallContext {
/** MCP tool name from `tools/call` */
toolName: string;
/** Unique id shared by every outbound request in this invocation */
correlationId: string;
}

export const toolCallContext = new AsyncLocalStorage<ToolCallContext>();

/**
* Returns `x-toolcall-id` header value (`{toolName}:{correlationId}`) for the
* current tool invocation, or `undefined` when not executing inside a tool handler.
*/
export function getToolCallIdHeader(): string | undefined {
const ctx = toolCallContext.getStore();
return ctx ? `${ctx.toolName}:${ctx.correlationId}` : undefined;
}
6 changes: 6 additions & 0 deletions packages/mcp/mcp-server-base/tests/auth-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,12 @@ describe('Auth Integration (HTTP transport → client → outbound fetch)', () =
expect(gqlReq).toBeDefined();
expect(gqlReq!.headers.authorization).toBe('Bearer startup-api-key-12345');
expect(gqlReq!.headers['x-transcend-active-organization-id']).toBeUndefined();
expect(gqlReq!.headers['user-agent']).toBe('Transcend-mcp');
const toolCallId = gqlReq!.headers['x-toolcall-id'] as string;
expect(toolCallId).toMatch(/^graphql_ping:/);
expect(toolCallId!.slice('graphql_ping:'.length)).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
});

it('per-request API key overrides the env var key', async () => {
Expand Down
59 changes: 59 additions & 0 deletions packages/mcp/mcp-server-base/tests/graphql-base.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { randomUUID } from 'node:crypto';

import { describe, it, expect, beforeEach, vi } from 'vitest';

import { requestAuthContext } from '../src/auth-context.js';
import type { AuthCredentials } from '../src/auth.js';
import { TranscendGraphQLBase } from '../src/clients/graphql/base.js';
import { ToolError, ErrorCode } from '../src/errors.js';
import { toolCallContext } from '../src/tool-call-context.js';

class TestGraphQLClient extends TranscendGraphQLBase {
async query<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
Expand Down Expand Up @@ -98,6 +101,7 @@ describe('TranscendGraphQLBase', () => {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': 'Transcend-mcp',
}),
}),
);
Expand All @@ -121,6 +125,7 @@ describe('TranscendGraphQLBase', () => {
'x-transcend-active-organization-id': 'org-uuid-456',
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': 'Transcend-mcp',
}),
}),
);
Expand All @@ -138,6 +143,60 @@ describe('TranscendGraphQLBase', () => {
const calledHeaders = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers;
expect(calledHeaders).not.toHaveProperty('Authorization');
});

it('does not send x-toolcall-id without tool call context', async () => {
const mockFetch = createMockFetchResponse({
data: { __typename: 'Query' },
});
vi.stubGlobal('fetch', mockFetch);

const client = new TestGraphQLClient(API_KEY_AUTH);
await client.query('query { __typename }');

const calledHeaders = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers;
expect(calledHeaders).not.toHaveProperty('x-toolcall-id');
});

it('sends x-toolcall-id when tool call context is active', async () => {
const mockFetch = createMockFetchResponse({
data: { __typename: 'Query' },
});
vi.stubGlobal('fetch', mockFetch);

const client = new TestGraphQLClient(API_KEY_AUTH);
const correlationId = randomUUID();
await toolCallContext.run({ toolName: 'my_tool', correlationId }, async () => {
await client.query('query { __typename }');
});

expect(fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'x-toolcall-id': `my_tool:${correlationId}`,
}),
}),
);
});

it('reuses the same x-toolcall-id for multiple requests in one tool call', async () => {
const mockFetch = createMockFetchResponse({
data: { __typename: 'Query' },
});
vi.stubGlobal('fetch', mockFetch);

const client = new TestGraphQLClient(API_KEY_AUTH);
const correlationId = randomUUID();
await toolCallContext.run({ toolName: 'multi_fetch', correlationId }, async () => {
await client.query('query { __typename }');
await client.query('query { __typename }');
});

const call0 = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers;
const call1 = (fetch as ReturnType<typeof vi.fn>).mock.calls[1][1].headers;
expect(call0['x-toolcall-id']).toBe(`multi_fetch:${correlationId}`);
expect(call1['x-toolcall-id']).toBe(call0['x-toolcall-id']);
});
});

describe('makeRequest - retries on 5xx', () => {
Expand Down
Loading