From 5fb469bbe02e9fd78d19afda60dfbab59c9cbbdd Mon Sep 17 00:00:00 2001 From: dawson-turechek-transcend Date: Fri, 8 May 2026 17:32:27 -0700 Subject: [PATCH 1/3] Adds user agent and tool call id --- .../src/clients/graphql/base.ts | 5 ++ .../src/clients/mcp-user-agent.ts | 2 + .../src/clients/rest-client.ts | 8 +++ packages/mcp/mcp-server-base/src/index.ts | 3 + .../src/server/build-server.ts | 8 ++- .../mcp-server-base/src/tool-call-context.ts | 23 ++++++++ .../tests/auth-integration.test.ts | 6 ++ .../tests/graphql-base.test.ts | 59 +++++++++++++++++++ 8 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 packages/mcp/mcp-server-base/src/clients/mcp-user-agent.ts create mode 100644 packages/mcp/mcp-server-base/src/tool-call-context.ts diff --git a/packages/mcp/mcp-server-base/src/clients/graphql/base.ts b/packages/mcp/mcp-server-base/src/clients/graphql/base.ts index dd76c7ba..ac9f2126 100644 --- a/packages/mcp/mcp-server-base/src/clients/graphql/base.ts +++ b/packages/mcp/mcp-server-base/src/clients/graphql/base.ts @@ -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`, @@ -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, diff --git a/packages/mcp/mcp-server-base/src/clients/mcp-user-agent.ts b/packages/mcp/mcp-server-base/src/clients/mcp-user-agent.ts new file mode 100644 index 00000000..18399c3a --- /dev/null +++ b/packages/mcp/mcp-server-base/src/clients/mcp-user-agent.ts @@ -0,0 +1,2 @@ +/** User-Agent sent on outbound Transcend API requests from MCP tool handlers. */ +export const TRANSCEND_MCP_USER_AGENT = 'Transcend-mcp'; diff --git a/packages/mcp/mcp-server-base/src/clients/rest-client.ts b/packages/mcp/mcp-server-base/src/clients/rest-client.ts index 0179e2c9..f3cd21b3 100644 --- a/packages/mcp/mcp-server-base/src/clients/rest-client.ts +++ b/packages/mcp/mcp-server-base/src/clients/rest-client.ts @@ -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, @@ -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; @@ -66,12 +68,15 @@ export class TranscendRestClient { ...fetchOptions } = options; + const toolCallId = getToolCallIdHeader(); const headers: Record = { ...authHeaders(effectiveAuth), 'Content-Type': 'application/json', Accept: 'application/json', 'X-Transcend-Version': '2021-11-15', ...((options.headers as Record) || {}), + 'User-Agent': TRANSCEND_MCP_USER_AGENT, + ...(toolCallId && { 'x-toolcall-id': toolCallId }), }; const controller = new AbortController(); @@ -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) { diff --git a/packages/mcp/mcp-server-base/src/index.ts b/packages/mcp/mcp-server-base/src/index.ts index d82db079..aeb209a1 100644 --- a/packages/mcp/mcp-server-base/src/index.ts +++ b/packages/mcp/mcp-server-base/src/index.ts @@ -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'; diff --git a/packages/mcp/mcp-server-base/src/server/build-server.ts b/packages/mcp/mcp-server-base/src/server/build-server.ts index ecad5827..f3dca69d 100644 --- a/packages/mcp/mcp-server-base/src/server/build-server.ts +++ b/packages/mcp/mcp-server-base/src/server/build-server.ts @@ -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'; @@ -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 { diff --git a/packages/mcp/mcp-server-base/src/tool-call-context.ts b/packages/mcp/mcp-server-base/src/tool-call-context.ts new file mode 100644 index 00000000..eda14a0d --- /dev/null +++ b/packages/mcp/mcp-server-base/src/tool-call-context.ts @@ -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(); + +/** + * 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; +} diff --git a/packages/mcp/mcp-server-base/tests/auth-integration.test.ts b/packages/mcp/mcp-server-base/tests/auth-integration.test.ts index 9e23481e..b7630a49 100644 --- a/packages/mcp/mcp-server-base/tests/auth-integration.test.ts +++ b/packages/mcp/mcp-server-base/tests/auth-integration.test.ts @@ -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 () => { diff --git a/packages/mcp/mcp-server-base/tests/graphql-base.test.ts b/packages/mcp/mcp-server-base/tests/graphql-base.test.ts index fbdd125a..33cb3e1a 100644 --- a/packages/mcp/mcp-server-base/tests/graphql-base.test.ts +++ b/packages/mcp/mcp-server-base/tests/graphql-base.test.ts @@ -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(query: string, variables?: Record): Promise { @@ -98,6 +101,7 @@ describe('TranscendGraphQLBase', () => { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json', Accept: 'application/json', + 'User-Agent': 'Transcend-mcp', }), }), ); @@ -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', }), }), ); @@ -138,6 +143,60 @@ describe('TranscendGraphQLBase', () => { const calledHeaders = (fetch as ReturnType).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).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).mock.calls[0][1].headers; + const call1 = (fetch as ReturnType).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', () => { From 5e34f0f7eb04899009c308a1900e5324739dc694 Mon Sep 17 00:00:00 2001 From: dawson-turechek-transcend Date: Fri, 8 May 2026 17:42:41 -0700 Subject: [PATCH 2/3] changeset --- .changeset/floppy-chicken-drum.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/floppy-chicken-drum.md diff --git a/.changeset/floppy-chicken-drum.md b/.changeset/floppy-chicken-drum.md new file mode 100644 index 00000000..51ad327c --- /dev/null +++ b/.changeset/floppy-chicken-drum.md @@ -0,0 +1,5 @@ +--- +"@transcend-io/mcp-server-base": patch +--- + +Adds user agent and toolcall headers mcp tool calls From d0de2636e3769ee7a6c3bf1bfbb70752420ba2f5 Mon Sep 17 00:00:00 2001 From: dawson-turechek-transcend Date: Mon, 11 May 2026 12:33:19 -0700 Subject: [PATCH 3/3] Adds x-transcend-mcp-caller to allowed headers --- .changeset/loose-ravens-prove.md | 5 +++++ packages/mcp/mcp-server-base/src/server/run-http.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/loose-ravens-prove.md diff --git a/.changeset/loose-ravens-prove.md b/.changeset/loose-ravens-prove.md new file mode 100644 index 00000000..bfadf6b2 --- /dev/null +++ b/.changeset/loose-ravens-prove.md @@ -0,0 +1,5 @@ +--- +"@transcend-io/mcp-server-base": patch +--- + +Adds x-transcend-mcp-caller to allowed headers diff --git a/packages/mcp/mcp-server-base/src/server/run-http.ts b/packages/mcp/mcp-server-base/src/server/run-http.ts index 831549bc..5b0eb3b6 100644 --- a/packages/mcp/mcp-server-base/src/server/run-http.ts +++ b/packages/mcp/mcp-server-base/src/server/run-http.ts @@ -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'],