diff --git a/src/index.ts b/src/index.ts index 359c5cd..929300c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { randomUUID } from 'node:crypto'; + import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { @@ -108,6 +110,7 @@ import { import { signWithLedger } from './tools/sign_with_ledger.js'; import { inspectXdr } from './tools/inspect_xdr.js'; } from './schemas/tools.js'; +import logger, { requestContext } from './logger.js'; import { signWithLedger } from './tools/sign_with_ledger.js'; CreateTrustlineInputSchema, ExportAiSchemasInputSchema, @@ -2605,6 +2608,99 @@ class PulsarServer { const action = args?.action; const params = args?.params; + // Extract or generate a unique request_id for tracing + const requestId = (argsObj?.request_id as string) || randomUUID(); + + return requestContext.run({ requestId }, async () => { + logger.debug({ tool: name, requestId }, 'Request context established'); + + switch (name) { + case 'get_account_balance': { + const parsed = GetAccountBalanceInputSchema.safeParse(args); + if (!parsed.success) { + throw new PulsarValidationError( + `Invalid input for get_account_balance`, + parsed.error.format() + ); + } + const result = await getAccountBalance(parsed.data); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result), + }, + ], + }; + } + + case 'fetch_contract_spec': { + const parsed = fetchContractSpecSchema.safeParse(args); + if (!parsed.success) { + throw new PulsarValidationError( + `Invalid input for fetch_contract_spec`, + parsed.error.format() + ); + } + const result = await fetchContractSpec(parsed.data); + return { content: [{ type: 'text', text: JSON.stringify(result) }] }; + } + + case 'submit_transaction': { + const parsed = SubmitTransactionInputSchema.safeParse(args); + if (!parsed.success) { + throw new PulsarValidationError( + `Invalid input for submit_transaction`, + parsed.error.format() + ); + } + const result = await submitTransaction(parsed.data); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + }; + } + + case 'simulate_transaction': { + const parsed = SimulateTransactionInputSchema.safeParse(args); + if (!parsed.success) { + throw new PulsarValidationError( + `Invalid input for simulate_transaction`, + parsed.error.format() + ); + } + const result = await simulateTransaction(parsed.data); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + }; + } + + case 'compute_vesting_schedule': { + const parsed = ComputeVestingScheduleInputSchema.safeParse(args); + if (!parsed.success) { + throw new PulsarValidationError( + `Invalid input for compute_vesting_schedule`, + parsed.error.format() + ); + } + const result = await computeVestingSchedule(parsed.data); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + }; + } + + case 'deploy_contract': { + const parsed = DeployContractInputSchema.safeParse(args); + if (!parsed.success) { + throw new PulsarValidationError( + `Invalid input for deploy_contract`, + parsed.error.format() + ); + } + const result = await deployContract(parsed.data); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + }; + } if (!action || !params) { throw new PulsarValidationError('AMM tool requires action and params'); } @@ -2933,20 +3029,24 @@ class PulsarServer { }; } - case 'create_claimable_balance': { - const parsed = CreateClaimableBalanceInputSchema.safeParse(args); - if (!parsed.success) { - throw new PulsarValidationError( - `Invalid input for create_claimable_balance`, - parsed.error.format() - ); + case 'create_claimable_balance': { + const parsed = CreateClaimableBalanceInputSchema.safeParse(args); + if (!parsed.success) { + throw new PulsarValidationError( + `Invalid input for create_claimable_balance`, + parsed.error.format() + ); + } + const result = await createClaimableBalance(parsed.data); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + }; } - const result = await createClaimableBalance(parsed.data); - return { - content: [{ type: 'text', text: JSON.stringify(result) }], - }; - } + default: + throw new McpError(ErrorCode.MethodNotFound, `Tool not found: ${name}`); + } + }); // end requestContext.run() default: throw new McpError(ErrorCode.MethodNotFound, `Tool not found: ${name}`); } diff --git a/src/logger.ts b/src/logger.ts index ef955ec..26b7d77 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,7 +1,15 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + import pino from 'pino'; import { config } from './config.js'; +/** + * requestContext provides an async storage for request-specific metadata + * like request_id to enable tracing across the call stack. + */ +export const requestContext = new AsyncLocalStorage<{ requestId: string }>(); + /** * Redacts sensitive fields from the logs, like private keys. */ @@ -14,6 +22,20 @@ const redactPaths = [ 'envelope_xdr', // While not strictly secret, it might contain sensitive info ]; +const logger = pino({ + level: process.env.LOG_LEVEL || 'info', + redact: redactPaths, + mixin() { + const store = requestContext.getStore(); + return store ? { request_id: store.requestId } : {}; + }, + transport: { + target: 'pino-pretty', + options: { + colorize: true, + destination: 2, // Write to stderr to avoid corrupting MCP stdout stream + translateTime: 'SYS:standard', + ignore: 'pid,hostname', const logger = pino( { level: config.logLevel, diff --git a/src/services/horizon.ts b/src/services/horizon.ts index 78f0c9f..185a399 100644 --- a/src/services/horizon.ts +++ b/src/services/horizon.ts @@ -1,5 +1,8 @@ import { Horizon } from '@stellar/stellar-sdk'; +import { config } from '../config.js'; +import { PulsarValidationError } from '../errors.js'; +import { requestContext } from '../logger.js'; import { config } from "../config.js"; import { PulsarValidationError } from "../errors.js"; import { accessControl } from "./access-control.js"; @@ -14,6 +17,11 @@ const NETWORK_HORIZON_URLS: Record = { export function getHorizonUrl(network?: string): string { const net = network ?? config.stellarNetwork; + if (net === 'custom') { + if (!config.horizonUrl) + throw new PulsarValidationError('HORIZON_URL must be set for custom network'); + return config.horizonUrl; + } if (net === "custom") { if (!config.horizonUrl) throw new PulsarValidationError("HORIZON_URL must be set for custom network"); accessControl.assertAllowed(config.horizonUrl); @@ -35,6 +43,9 @@ export function getHorizonUrl(network?: string): string { const serverCache = new Map(); export function getHorizonServer(network?: string): Horizon.Server { + const store = requestContext.getStore(); + const headers = store ? { 'X-Request-ID': store.requestId } : undefined; + return new Horizon.Server(getHorizonUrl(network), { allowHttp: true, headers }); const url = getHorizonUrl(network); let server = serverCache.get(url); if (!server) { diff --git a/tests/unit/tracing.test.ts b/tests/unit/tracing.test.ts new file mode 100644 index 0000000..bbf641d --- /dev/null +++ b/tests/unit/tracing.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { requestContext } from '../../src/logger.js'; + +// Isolate each test +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('Request ID Tracing', () => { + it('should have no store outside of a request context', () => { + const store = requestContext.getStore(); + expect(store).toBeUndefined(); + }); + + it('should expose the requestId within a run() call', () => { + const requestId = 'test-request-id-abc123'; + requestContext.run({ requestId }, () => { + const store = requestContext.getStore(); + expect(store).toBeDefined(); + expect(store?.requestId).toBe(requestId); + }); + }); + + it('should isolate requestId between concurrent contexts', async () => { + const id1 = 'request-id-1'; + const id2 = 'request-id-2'; + + const results: string[] = []; + + await Promise.all([ + new Promise((resolve) => { + requestContext.run({ requestId: id1 }, async () => { + // Simulate async work + await new Promise((r) => setTimeout(r, 10)); + results.push(requestContext.getStore()!.requestId); + resolve(); + }); + }), + new Promise((resolve) => { + requestContext.run({ requestId: id2 }, async () => { + // Simulate async work (resolves first) + await new Promise((r) => setTimeout(r, 5)); + results.push(requestContext.getStore()!.requestId); + resolve(); + }); + }), + ]); + + // Both IDs should be present, each in its own isolated context + expect(results).toContain(id1); + expect(results).toContain(id2); + // Verify isolation: each async branch read its own ID, not the other's + expect(results).toHaveLength(2); + expect(results[0]).not.toBe(results[1]); + }); + + it('should return undefined after run() completes', () => { + const requestId = 'ephemeral-id'; + requestContext.run({ requestId }, () => { + // Inside the context + expect(requestContext.getStore()?.requestId).toBe(requestId); + }); + // Outside the context + expect(requestContext.getStore()).toBeUndefined(); + }); +});