@access-mcp/shared 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,9 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
- import { BaseAccessServer } from "../base-server.js";
2
+ import { BaseAccessServer, getRequestContext, getActingUser, getActingUserUid, requestContextStorage, } from "../base-server.js";
3
3
  // Concrete implementation for testing
4
4
  class TestServer extends BaseAccessServer {
5
+ // Store the last captured context for verification
6
+ lastCapturedContext;
5
7
  constructor() {
6
8
  super("test-server", "1.0.0", "https://api.example.com");
7
9
  }
@@ -17,6 +19,14 @@ class TestServer extends BaseAccessServer {
17
19
  },
18
20
  },
19
21
  },
22
+ {
23
+ name: "get_context",
24
+ description: "Returns the current request context",
25
+ inputSchema: {
26
+ type: "object",
27
+ properties: {},
28
+ },
29
+ },
20
30
  ];
21
31
  }
22
32
  getResources() {
@@ -29,6 +39,23 @@ class TestServer extends BaseAccessServer {
29
39
  ];
30
40
  }
31
41
  async handleToolCall(request) {
42
+ // Capture the context for verification
43
+ this.lastCapturedContext = getRequestContext();
44
+ if (request.params.name === "get_context") {
45
+ const context = getRequestContext();
46
+ return {
47
+ content: [
48
+ {
49
+ type: "text",
50
+ text: JSON.stringify({
51
+ actingUser: context?.actingUser,
52
+ actingUserUid: context?.actingUserUid,
53
+ requestId: context?.requestId,
54
+ }),
55
+ },
56
+ ],
57
+ };
58
+ }
32
59
  if (request.params.name === "test_tool") {
33
60
  const args = request.params.arguments;
34
61
  return {
@@ -72,8 +99,9 @@ describe("BaseAccessServer HTTP Mode", () => {
72
99
  const response = await fetch(`${baseUrl}/tools`);
73
100
  const data = await response.json();
74
101
  expect(response.status).toBe(200);
75
- expect(data.tools).toHaveLength(1);
76
- expect(data.tools[0].name).toBe("test_tool");
102
+ expect(data.tools).toHaveLength(2);
103
+ expect(data.tools.map((t) => t.name)).toContain("test_tool");
104
+ expect(data.tools.map((t) => t.name)).toContain("get_context");
77
105
  });
78
106
  });
79
107
  describe("Tool execution endpoint", () => {
@@ -174,3 +202,288 @@ describe("BaseAccessServer helper methods", () => {
174
202
  });
175
203
  });
176
204
  });
205
+ describe("Request Context", () => {
206
+ describe("getRequestContext", () => {
207
+ it("should return undefined outside of request context", () => {
208
+ const context = getRequestContext();
209
+ expect(context).toBeUndefined();
210
+ });
211
+ it("should return context when inside requestContextStorage.run", () => {
212
+ const testContext = {
213
+ actingUser: "testuser@access-ci.org",
214
+ actingUserUid: 12345,
215
+ requestId: "req-123",
216
+ };
217
+ requestContextStorage.run(testContext, () => {
218
+ const context = getRequestContext();
219
+ expect(context).toBeDefined();
220
+ expect(context?.actingUser).toBe("testuser@access-ci.org");
221
+ expect(context?.actingUserUid).toBe(12345);
222
+ expect(context?.requestId).toBe("req-123");
223
+ });
224
+ });
225
+ });
226
+ describe("getActingUser", () => {
227
+ it("should throw when no acting user is set", () => {
228
+ expect(() => getActingUser()).toThrow("No acting user specified");
229
+ });
230
+ it("should throw when context exists but actingUser is undefined", () => {
231
+ const testContext = {
232
+ requestId: "req-123",
233
+ };
234
+ requestContextStorage.run(testContext, () => {
235
+ expect(() => getActingUser()).toThrow("No acting user specified");
236
+ });
237
+ });
238
+ it("should return acting user when set", () => {
239
+ const testContext = {
240
+ actingUser: "researcher@access-ci.org",
241
+ };
242
+ requestContextStorage.run(testContext, () => {
243
+ expect(getActingUser()).toBe("researcher@access-ci.org");
244
+ });
245
+ });
246
+ });
247
+ describe("getActingUserUid", () => {
248
+ it("should throw when no acting user UID is set", () => {
249
+ expect(() => getActingUserUid()).toThrow("No acting user UID specified");
250
+ });
251
+ it("should throw when context exists but actingUserUid is undefined", () => {
252
+ const testContext = {
253
+ actingUser: "testuser@access-ci.org",
254
+ };
255
+ requestContextStorage.run(testContext, () => {
256
+ expect(() => getActingUserUid()).toThrow("No acting user UID specified");
257
+ });
258
+ });
259
+ it("should return acting user UID when set", () => {
260
+ const testContext = {
261
+ actingUserUid: 98765,
262
+ };
263
+ requestContextStorage.run(testContext, () => {
264
+ expect(getActingUserUid()).toBe(98765);
265
+ });
266
+ });
267
+ });
268
+ });
269
+ describe("HTTP Mode - Acting User Headers", () => {
270
+ let server;
271
+ let port;
272
+ let baseUrl;
273
+ beforeEach(async () => {
274
+ server = new TestServer();
275
+ port = 3200 + Math.floor(Math.random() * 100);
276
+ baseUrl = `http://localhost:${port}`;
277
+ await server.start({ httpPort: port });
278
+ });
279
+ describe("X-Acting-User header", () => {
280
+ it("should extract X-Acting-User header and make it available in context", async () => {
281
+ const response = await fetch(`${baseUrl}/tools/get_context`, {
282
+ method: "POST",
283
+ headers: {
284
+ "Content-Type": "application/json",
285
+ "X-Acting-User": "researcher@access-ci.org",
286
+ },
287
+ body: JSON.stringify({ arguments: {} }),
288
+ });
289
+ expect(response.status).toBe(200);
290
+ const data = await response.json();
291
+ const result = JSON.parse(data.content[0].text);
292
+ expect(result.actingUser).toBe("researcher@access-ci.org");
293
+ });
294
+ it("should extract X-Acting-User-Uid header and parse as number", async () => {
295
+ const response = await fetch(`${baseUrl}/tools/get_context`, {
296
+ method: "POST",
297
+ headers: {
298
+ "Content-Type": "application/json",
299
+ "X-Acting-User-Uid": "12345",
300
+ },
301
+ body: JSON.stringify({ arguments: {} }),
302
+ });
303
+ expect(response.status).toBe(200);
304
+ const data = await response.json();
305
+ const result = JSON.parse(data.content[0].text);
306
+ expect(result.actingUserUid).toBe(12345);
307
+ });
308
+ it("should extract X-Request-ID header", async () => {
309
+ const response = await fetch(`${baseUrl}/tools/get_context`, {
310
+ method: "POST",
311
+ headers: {
312
+ "Content-Type": "application/json",
313
+ "X-Request-ID": "req-abc-123",
314
+ },
315
+ body: JSON.stringify({ arguments: {} }),
316
+ });
317
+ expect(response.status).toBe(200);
318
+ const data = await response.json();
319
+ const result = JSON.parse(data.content[0].text);
320
+ expect(result.requestId).toBe("req-abc-123");
321
+ });
322
+ it("should extract all headers together", async () => {
323
+ const response = await fetch(`${baseUrl}/tools/get_context`, {
324
+ method: "POST",
325
+ headers: {
326
+ "Content-Type": "application/json",
327
+ "X-Acting-User": "admin@access-ci.org",
328
+ "X-Acting-User-Uid": "999",
329
+ "X-Request-ID": "full-context-test",
330
+ },
331
+ body: JSON.stringify({ arguments: {} }),
332
+ });
333
+ expect(response.status).toBe(200);
334
+ const data = await response.json();
335
+ const result = JSON.parse(data.content[0].text);
336
+ expect(result.actingUser).toBe("admin@access-ci.org");
337
+ expect(result.actingUserUid).toBe(999);
338
+ expect(result.requestId).toBe("full-context-test");
339
+ });
340
+ it("should handle missing headers gracefully", async () => {
341
+ const response = await fetch(`${baseUrl}/tools/get_context`, {
342
+ method: "POST",
343
+ headers: {
344
+ "Content-Type": "application/json",
345
+ },
346
+ body: JSON.stringify({ arguments: {} }),
347
+ });
348
+ expect(response.status).toBe(200);
349
+ const data = await response.json();
350
+ const result = JSON.parse(data.content[0].text);
351
+ expect(result.actingUser).toBeUndefined();
352
+ expect(result.actingUserUid).toBeUndefined();
353
+ expect(result.requestId).toBeUndefined();
354
+ });
355
+ it("should handle invalid UID header gracefully", async () => {
356
+ const response = await fetch(`${baseUrl}/tools/get_context`, {
357
+ method: "POST",
358
+ headers: {
359
+ "Content-Type": "application/json",
360
+ "X-Acting-User-Uid": "not-a-number",
361
+ },
362
+ body: JSON.stringify({ arguments: {} }),
363
+ });
364
+ expect(response.status).toBe(200);
365
+ const data = await response.json();
366
+ const result = JSON.parse(data.content[0].text);
367
+ // NaN becomes null when JSON serialized
368
+ expect(result.actingUserUid).toBeNull();
369
+ });
370
+ });
371
+ });
372
+ // Server that requires API key
373
+ class ApiKeyRequiredServer extends BaseAccessServer {
374
+ constructor() {
375
+ super("apikey-server", "1.0.0", "https://api.example.com", {
376
+ requireApiKey: true,
377
+ });
378
+ }
379
+ getTools() {
380
+ return [
381
+ {
382
+ name: "protected_tool",
383
+ description: "A tool that requires API key",
384
+ inputSchema: {
385
+ type: "object",
386
+ properties: {},
387
+ },
388
+ },
389
+ ];
390
+ }
391
+ getResources() {
392
+ return [];
393
+ }
394
+ async handleToolCall(request) {
395
+ return {
396
+ content: [
397
+ {
398
+ type: "text",
399
+ text: JSON.stringify({ success: true, tool: request.params.name }),
400
+ },
401
+ ],
402
+ };
403
+ }
404
+ }
405
+ describe("API Key Authentication", () => {
406
+ let server;
407
+ let port;
408
+ let baseUrl;
409
+ const TEST_API_KEY = "test-secret-api-key-12345";
410
+ beforeEach(async () => {
411
+ server = new ApiKeyRequiredServer();
412
+ port = 3300 + Math.floor(Math.random() * 100);
413
+ baseUrl = `http://localhost:${port}`;
414
+ // Set the API key environment variable
415
+ process.env.MCP_API_KEY = TEST_API_KEY;
416
+ await server.start({ httpPort: port });
417
+ });
418
+ afterEach(() => {
419
+ delete process.env.MCP_API_KEY;
420
+ });
421
+ describe("Tool endpoints with requireApiKey", () => {
422
+ it("should reject requests without API key", async () => {
423
+ const response = await fetch(`${baseUrl}/tools/protected_tool`, {
424
+ method: "POST",
425
+ headers: {
426
+ "Content-Type": "application/json",
427
+ },
428
+ body: JSON.stringify({ arguments: {} }),
429
+ });
430
+ expect(response.status).toBe(401);
431
+ const data = await response.json();
432
+ expect(data.error).toContain("Invalid or missing API key");
433
+ });
434
+ it("should reject requests with incorrect API key", async () => {
435
+ const response = await fetch(`${baseUrl}/tools/protected_tool`, {
436
+ method: "POST",
437
+ headers: {
438
+ "Content-Type": "application/json",
439
+ "X-Api-Key": "wrong-key",
440
+ },
441
+ body: JSON.stringify({ arguments: {} }),
442
+ });
443
+ expect(response.status).toBe(401);
444
+ const data = await response.json();
445
+ expect(data.error).toContain("Invalid or missing API key");
446
+ });
447
+ it("should accept requests with correct API key", async () => {
448
+ const response = await fetch(`${baseUrl}/tools/protected_tool`, {
449
+ method: "POST",
450
+ headers: {
451
+ "Content-Type": "application/json",
452
+ "X-Api-Key": TEST_API_KEY,
453
+ },
454
+ body: JSON.stringify({ arguments: {} }),
455
+ });
456
+ expect(response.status).toBe(200);
457
+ const data = await response.json();
458
+ const result = JSON.parse(data.content[0].text);
459
+ expect(result.success).toBe(true);
460
+ });
461
+ it("should allow health endpoint without API key", async () => {
462
+ const response = await fetch(`${baseUrl}/health`);
463
+ expect(response.status).toBe(200);
464
+ });
465
+ it("should allow tools listing without API key", async () => {
466
+ const response = await fetch(`${baseUrl}/tools`);
467
+ expect(response.status).toBe(200);
468
+ const data = await response.json();
469
+ expect(data.tools).toHaveLength(1);
470
+ });
471
+ });
472
+ describe("Server misconfiguration", () => {
473
+ it("should return 500 when MCP_API_KEY is not configured", async () => {
474
+ // Remove the API key
475
+ delete process.env.MCP_API_KEY;
476
+ const response = await fetch(`${baseUrl}/tools/protected_tool`, {
477
+ method: "POST",
478
+ headers: {
479
+ "Content-Type": "application/json",
480
+ "X-Api-Key": "some-key",
481
+ },
482
+ body: JSON.stringify({ arguments: {} }),
483
+ });
484
+ expect(response.status).toBe(500);
485
+ const data = await response.json();
486
+ expect(data.error).toContain("Server misconfiguration");
487
+ });
488
+ });
489
+ });
@@ -2,8 +2,58 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { Tool, Resource, Prompt, CallToolResult, ReadResourceResult, GetPromptResult, CallToolRequest, ReadResourceRequest, GetPromptRequest } from "@modelcontextprotocol/sdk/types.js";
4
4
  import { AxiosInstance } from "axios";
5
+ import { AsyncLocalStorage } from "node:async_hooks";
5
6
  import { Logger } from "./logger.js";
6
7
  export type { Tool, Resource, Prompt, CallToolResult, ReadResourceResult, GetPromptResult };
8
+ /**
9
+ * Request context passed via AsyncLocalStorage for per-request data.
10
+ * This allows tool handlers to access request-scoped data like the acting user
11
+ * without needing to thread it through every function call.
12
+ */
13
+ export interface RequestContext {
14
+ /**
15
+ * The ACCESS ID of the user performing the action.
16
+ * Passed via X-Acting-User header from the agent.
17
+ * Format: "username@access-ci.org"
18
+ */
19
+ actingUser?: string;
20
+ /**
21
+ * The Drupal user ID of the acting user.
22
+ * Passed via X-Acting-User-Uid header from the agent.
23
+ * Used for content attribution in Drupal.
24
+ */
25
+ actingUserUid?: number;
26
+ /** Unique request ID for tracing (from X-Request-ID header) */
27
+ requestId?: string;
28
+ }
29
+ /** AsyncLocalStorage instance for request context */
30
+ export declare const requestContextStorage: AsyncLocalStorage<RequestContext>;
31
+ /**
32
+ * Get the current request context. Returns undefined if called outside a request.
33
+ */
34
+ export declare function getRequestContext(): RequestContext | undefined;
35
+ /**
36
+ * Get the acting user's ACCESS ID from the current request context.
37
+ * Throws an error if no acting user is set - use this for operations that require attribution.
38
+ */
39
+ export declare function getActingUser(): string;
40
+ /**
41
+ * Get the acting user's Drupal UID from the current request context.
42
+ * Throws an error if no acting user UID is set - use this for Drupal content attribution.
43
+ */
44
+ export declare function getActingUserUid(): number;
45
+ /**
46
+ * Options for BaseAccessServer constructor
47
+ */
48
+ export interface BaseAccessServerOptions {
49
+ /**
50
+ * Require API key authentication for tool execution endpoints.
51
+ * When enabled, requests to /tools/:toolName must include a valid
52
+ * X-Api-Key header matching the MCP_API_KEY environment variable.
53
+ * Enable this for servers that perform write operations on behalf of users.
54
+ */
55
+ requireApiKey?: boolean;
56
+ }
7
57
  export declare abstract class BaseAccessServer {
8
58
  protected serverName: string;
9
59
  protected version: string;
@@ -15,7 +65,8 @@ export declare abstract class BaseAccessServer {
15
65
  private _httpServer?;
16
66
  private _httpPort?;
17
67
  private _sseTransports;
18
- constructor(serverName: string, version: string, baseURL?: string);
68
+ private _requireApiKey;
69
+ constructor(serverName: string, version: string, baseURL?: string, options?: BaseAccessServerOptions);
19
70
  protected get httpClient(): AxiosInstance;
20
71
  private setupHandlers;
21
72
  protected abstract getTools(): Tool[];
@@ -90,7 +141,8 @@ export declare abstract class BaseAccessServer {
90
141
  */
91
142
  private setupServerHandlers;
92
143
  /**
93
- * Call a tool on another ACCESS-CI MCP server via HTTP
144
+ * Call a tool on another ACCESS-CI MCP server via HTTP.
145
+ * Automatically forwards acting user context from the current request.
94
146
  */
95
147
  protected callRemoteServer(serviceName: string, toolName: string, args?: Record<string, unknown>): Promise<unknown>;
96
148
  /**
@@ -4,7 +4,41 @@ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
4
4
  import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
5
  import axios from "axios";
6
6
  import express from "express";
7
+ import { AsyncLocalStorage } from "node:async_hooks";
7
8
  import { createLogger } from "./logger.js";
9
+ import { traceMcpToolCall } from "./telemetry.js";
10
+ /** AsyncLocalStorage instance for request context */
11
+ export const requestContextStorage = new AsyncLocalStorage();
12
+ /**
13
+ * Get the current request context. Returns undefined if called outside a request.
14
+ */
15
+ export function getRequestContext() {
16
+ return requestContextStorage.getStore();
17
+ }
18
+ /**
19
+ * Get the acting user's ACCESS ID from the current request context.
20
+ * Throws an error if no acting user is set - use this for operations that require attribution.
21
+ */
22
+ export function getActingUser() {
23
+ const context = getRequestContext();
24
+ if (!context?.actingUser) {
25
+ throw new Error("No acting user specified. The X-Acting-User header must be set to the ACCESS ID " +
26
+ "(username@access-ci.org) of the person performing this action.");
27
+ }
28
+ return context.actingUser;
29
+ }
30
+ /**
31
+ * Get the acting user's Drupal UID from the current request context.
32
+ * Throws an error if no acting user UID is set - use this for Drupal content attribution.
33
+ */
34
+ export function getActingUserUid() {
35
+ const context = getRequestContext();
36
+ if (!context?.actingUserUid) {
37
+ throw new Error("No acting user UID specified. The X-Acting-User-Uid header must be set to the Drupal user ID " +
38
+ "of the person performing this action.");
39
+ }
40
+ return context.actingUserUid;
41
+ }
8
42
  export class BaseAccessServer {
9
43
  serverName;
10
44
  version;
@@ -16,10 +50,15 @@ export class BaseAccessServer {
16
50
  _httpServer;
17
51
  _httpPort;
18
52
  _sseTransports = new Map();
19
- constructor(serverName, version, baseURL = "https://support.access-ci.org/api") {
53
+ _requireApiKey;
54
+ constructor(serverName, version, baseURL = "https://support.access-ci.org/api", options = {}) {
20
55
  this.serverName = serverName;
21
56
  this.version = version;
22
57
  this.baseURL = baseURL;
58
+ this._requireApiKey = options.requireApiKey ?? false;
59
+ // Note: Telemetry is initialized via --import flag in the Dockerfile
60
+ // This ensures OpenTelemetry is set up BEFORE express/http are imported
61
+ // See packages/shared/src/telemetry-bootstrap.ts
23
62
  this.logger = createLogger(serverName);
24
63
  this.server = new Server({
25
64
  name: serverName,
@@ -297,31 +336,74 @@ export class BaseAccessServer {
297
336
  });
298
337
  // Tool execution endpoint (for inter-server communication)
299
338
  this._httpServer.post("/tools/:toolName", async (req, res) => {
300
- try {
301
- const { toolName } = req.params;
302
- const { arguments: args = {} } = req.body;
303
- // Validate that the tool exists
304
- const tools = this.getTools();
305
- const tool = tools.find((t) => t.name === toolName);
306
- if (!tool) {
307
- res.status(404).json({ error: `Tool '${toolName}' not found` });
339
+ // Validate API key if required
340
+ if (this._requireApiKey) {
341
+ const expectedApiKey = process.env.MCP_API_KEY;
342
+ const providedApiKey = req.header("X-Api-Key");
343
+ if (!expectedApiKey) {
344
+ this.logger.error("MCP_API_KEY environment variable not set but requireApiKey is enabled");
345
+ res.status(500).json({ error: "Server misconfiguration: API key not configured" });
346
+ return;
347
+ }
348
+ if (!providedApiKey || providedApiKey !== expectedApiKey) {
349
+ this.logger.warn("Unauthorized tool call attempt", {
350
+ tool: req.params.toolName,
351
+ hasKey: !!providedApiKey,
352
+ });
353
+ res.status(401).json({ error: "Invalid or missing API key" });
308
354
  return;
309
355
  }
310
- // Execute the tool
311
- const request = {
312
- method: "tools/call",
313
- params: {
314
- name: toolName,
315
- arguments: args,
316
- },
317
- };
318
- const result = await this.handleToolCall(request);
319
- res.json(result);
320
356
  }
321
- catch (error) {
322
- const errorMessage = error instanceof Error ? error.message : String(error);
323
- res.status(500).json({ error: errorMessage });
357
+ // Extract request context from headers
358
+ const uidHeader = req.header("X-Acting-User-Uid");
359
+ const context = {
360
+ actingUser: req.header("X-Acting-User"),
361
+ actingUserUid: uidHeader ? parseInt(uidHeader, 10) : undefined,
362
+ requestId: req.header("X-Request-ID"),
363
+ };
364
+ const { toolName } = req.params;
365
+ const { arguments: args = {} } = req.body;
366
+ // Validate that the tool exists (before tracing to avoid noisy spans)
367
+ const tools = this.getTools();
368
+ const tool = tools.find((t) => t.name === toolName);
369
+ if (!tool) {
370
+ res.status(404).json({ error: `Tool '${toolName}' not found` });
371
+ return;
324
372
  }
373
+ // Run the handler within the request context
374
+ await requestContextStorage.run(context, async () => {
375
+ // Wrap tool execution with OpenTelemetry tracing
376
+ try {
377
+ const result = await traceMcpToolCall(toolName, args, async (span) => {
378
+ // Add server info to span
379
+ span.setAttribute("mcp.server.name", this.serverName);
380
+ span.setAttribute("mcp.server.version", this.version);
381
+ // Add request context if available
382
+ if (context.requestId) {
383
+ span.setAttribute("mcp.request.id", context.requestId);
384
+ }
385
+ // Execute the tool
386
+ const request = {
387
+ method: "tools/call",
388
+ params: {
389
+ name: toolName,
390
+ arguments: args,
391
+ },
392
+ };
393
+ const toolResult = await this.handleToolCall(request);
394
+ // Record result info on span
395
+ if (toolResult.isError) {
396
+ span.setAttribute("mcp.result.is_error", true);
397
+ }
398
+ return toolResult;
399
+ });
400
+ res.json(result);
401
+ }
402
+ catch (error) {
403
+ const errorMessage = error instanceof Error ? error.message : String(error);
404
+ res.status(500).json({ error: errorMessage });
405
+ }
406
+ });
325
407
  });
326
408
  // Start HTTP server
327
409
  return new Promise((resolve, reject) => {
@@ -408,18 +490,32 @@ export class BaseAccessServer {
408
490
  });
409
491
  }
410
492
  /**
411
- * Call a tool on another ACCESS-CI MCP server via HTTP
493
+ * Call a tool on another ACCESS-CI MCP server via HTTP.
494
+ * Automatically forwards acting user context from the current request.
412
495
  */
413
496
  async callRemoteServer(serviceName, toolName, args = {}) {
414
497
  const serviceUrl = this.getServiceEndpoint(serviceName);
415
498
  if (!serviceUrl) {
416
499
  throw new Error(`Service '${serviceName}' not found. Check ACCESS_MCP_SERVICES environment variable.`);
417
500
  }
501
+ // Forward acting user context to the remote server
502
+ const context = getRequestContext();
503
+ const headers = {};
504
+ if (context?.actingUser) {
505
+ headers["X-Acting-User"] = context.actingUser;
506
+ }
507
+ if (context?.actingUserUid) {
508
+ headers["X-Acting-User-Uid"] = String(context.actingUserUid);
509
+ }
510
+ if (context?.requestId) {
511
+ headers["X-Request-ID"] = context.requestId;
512
+ }
418
513
  const response = await axios.post(`${serviceUrl}/tools/${toolName}`, {
419
514
  arguments: args,
420
515
  }, {
421
516
  timeout: 30000,
422
517
  validateStatus: () => true,
518
+ headers,
423
519
  });
424
520
  if (response.status !== 200) {
425
521
  throw new Error(`Remote server call failed: ${response.status} ${response.data?.error || response.statusText}`);
@@ -16,7 +16,17 @@ export declare class DrupalAuthProvider {
16
16
  private userUuid?;
17
17
  private httpClient;
18
18
  private isAuthenticated;
19
- constructor(baseUrl: string, username: string, password: string);
19
+ private actingUser?;
20
+ constructor(baseUrl: string, username: string, password: string, actingUser?: string);
21
+ /**
22
+ * Set the acting user (ACCESS ID) for attribution.
23
+ * This user will be set as the author of created content.
24
+ */
25
+ setActingUser(accessId: string): void;
26
+ /**
27
+ * Get the current acting user
28
+ */
29
+ getActingUser(): string | undefined;
20
30
  /**
21
31
  * Ensure we have a valid session, logging in if necessary
22
32
  */
@@ -1,4 +1,6 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any -- Generic HTTP client for untyped JSON:API responses */
1
2
  import axios from "axios";
3
+ import { randomUUID } from "crypto";
2
4
  /**
3
5
  * Authentication provider for Drupal JSON:API using cookie-based auth.
4
6
  *
@@ -17,16 +19,31 @@ export class DrupalAuthProvider {
17
19
  userUuid;
18
20
  httpClient;
19
21
  isAuthenticated = false;
20
- constructor(baseUrl, username, password) {
22
+ actingUser;
23
+ constructor(baseUrl, username, password, actingUser) {
21
24
  this.baseUrl = baseUrl;
22
25
  this.username = username;
23
26
  this.password = password;
27
+ this.actingUser = actingUser;
24
28
  this.httpClient = axios.create({
25
29
  baseURL: this.baseUrl,
26
30
  timeout: 30000,
27
31
  validateStatus: () => true,
28
32
  });
29
33
  }
34
+ /**
35
+ * Set the acting user (ACCESS ID) for attribution.
36
+ * This user will be set as the author of created content.
37
+ */
38
+ setActingUser(accessId) {
39
+ this.actingUser = accessId;
40
+ }
41
+ /**
42
+ * Get the current acting user
43
+ */
44
+ getActingUser() {
45
+ return this.actingUser;
46
+ }
30
47
  /**
31
48
  * Ensure we have a valid session, logging in if necessary
32
49
  */
@@ -73,12 +90,18 @@ export class DrupalAuthProvider {
73
90
  if (!this.isAuthenticated || !this.sessionCookie || !this.csrfToken) {
74
91
  throw new Error("Not authenticated. Call ensureAuthenticated() first.");
75
92
  }
76
- return {
93
+ const headers = {
77
94
  Cookie: this.sessionCookie,
78
95
  "X-CSRF-Token": this.csrfToken,
79
96
  "Content-Type": "application/vnd.api+json",
80
97
  Accept: "application/vnd.api+json",
98
+ "X-Request-ID": randomUUID(),
81
99
  };
100
+ // Include acting user header if set - Drupal will use this for attribution
101
+ if (this.actingUser) {
102
+ headers["X-Acting-User"] = this.actingUser;
103
+ }
104
+ return headers;
82
105
  }
83
106
  /**
84
107
  * Get the authenticated user's UUID
package/dist/index.d.ts CHANGED
@@ -4,3 +4,4 @@ export * from "./utils.js";
4
4
  export * from "./taxonomies.js";
5
5
  export * from "./drupal-auth.js";
6
6
  export * from "./logger.js";
7
+ export * from "./telemetry.js";
package/dist/index.js CHANGED
@@ -4,3 +4,4 @@ export * from "./utils.js";
4
4
  export * from "./taxonomies.js";
5
5
  export * from "./drupal-auth.js";
6
6
  export * from "./logger.js";
7
+ export * from "./telemetry.js";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Early telemetry bootstrap for OpenTelemetry auto-instrumentation.
3
+ *
4
+ * IMPORTANT: This file must be loaded BEFORE the main server script.
5
+ * OpenTelemetry auto-instrumentation only works if the SDK is initialized before
6
+ * the modules it instruments (express, http) are loaded.
7
+ *
8
+ * Usage: Load via Node.js --import flag in Dockerfile:
9
+ * CMD ["node", "--import", "./packages/shared/dist/telemetry-bootstrap.js", "packages/${PACKAGE}/dist/index.js"]
10
+ *
11
+ * The bootstrap automatically reads the server's package.json to get name/version.
12
+ */
13
+ export {};
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Early telemetry bootstrap for OpenTelemetry auto-instrumentation.
3
+ *
4
+ * IMPORTANT: This file must be loaded BEFORE the main server script.
5
+ * OpenTelemetry auto-instrumentation only works if the SDK is initialized before
6
+ * the modules it instruments (express, http) are loaded.
7
+ *
8
+ * Usage: Load via Node.js --import flag in Dockerfile:
9
+ * CMD ["node", "--import", "./packages/shared/dist/telemetry-bootstrap.js", "packages/${PACKAGE}/dist/index.js"]
10
+ *
11
+ * The bootstrap automatically reads the server's package.json to get name/version.
12
+ */
13
+ import { NodeSDK } from "@opentelemetry/sdk-node";
14
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
15
+ import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
16
+ import { ExpressInstrumentation } from "@opentelemetry/instrumentation-express";
17
+ import { resourceFromAttributes } from "@opentelemetry/resources";
18
+ import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION, SEMRESATTRS_DEPLOYMENT_ENVIRONMENT, } from "@opentelemetry/semantic-conventions";
19
+ import { readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ /**
22
+ * Read the package.json for the target package.
23
+ * Uses the PACKAGE env var set by the Dockerfile to locate the package.
24
+ */
25
+ function getPackageInfo() {
26
+ const packageName = process.env.PACKAGE;
27
+ if (!packageName) {
28
+ return { name: "unknown-mcp-server", version: "0.0.0" };
29
+ }
30
+ try {
31
+ // In Docker, packages are at /app/packages/${PACKAGE}
32
+ const pkgPath = join(process.cwd(), "packages", packageName, "package.json");
33
+ const content = readFileSync(pkgPath, "utf-8");
34
+ return JSON.parse(content);
35
+ }
36
+ catch {
37
+ return { name: `mcp-${packageName}`, version: "0.0.0" };
38
+ }
39
+ }
40
+ function bootstrap() {
41
+ const pkg = getPackageInfo();
42
+ const serviceName = pkg.name;
43
+ const serviceVersion = pkg.version;
44
+ // Check if telemetry is disabled
45
+ if (process.env.OTEL_ENABLED === "false") {
46
+ console.log(`[${serviceName}] OpenTelemetry disabled via OTEL_ENABLED=false`);
47
+ return;
48
+ }
49
+ const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
50
+ if (!endpoint) {
51
+ console.log(`[${serviceName}] OpenTelemetry disabled: no OTEL_EXPORTER_OTLP_ENDPOINT`);
52
+ return;
53
+ }
54
+ // Parse headers from environment (format: "key1=value1,key2=value2")
55
+ // Note: Values may contain '=' (e.g., base64), so only split on first '='
56
+ const headersStr = process.env.OTEL_EXPORTER_OTLP_HEADERS || "";
57
+ const headers = {};
58
+ headersStr.split(",").forEach((pair) => {
59
+ const eqIndex = pair.indexOf("=");
60
+ if (eqIndex > 0) {
61
+ const key = pair.slice(0, eqIndex).trim();
62
+ const value = pair.slice(eqIndex + 1).trim();
63
+ if (key && value) {
64
+ headers[key] = value;
65
+ }
66
+ }
67
+ });
68
+ // Set Honeycomb dataset header
69
+ const dataset = process.env.HONEYCOMB_DATASET || "access-ci";
70
+ headers["x-honeycomb-dataset"] = dataset;
71
+ const traceExporter = new OTLPTraceExporter({
72
+ url: `${endpoint}/v1/traces`,
73
+ headers,
74
+ });
75
+ const resource = resourceFromAttributes({
76
+ [SEMRESATTRS_SERVICE_NAME]: dataset,
77
+ [SEMRESATTRS_SERVICE_VERSION]: serviceVersion,
78
+ [SEMRESATTRS_DEPLOYMENT_ENVIRONMENT]: process.env.ENVIRONMENT || "local",
79
+ "service.component": serviceName,
80
+ });
81
+ const sdk = new NodeSDK({
82
+ resource,
83
+ traceExporter,
84
+ instrumentations: [new HttpInstrumentation(), new ExpressInstrumentation()],
85
+ });
86
+ sdk.start();
87
+ console.log(`[${serviceName}] OpenTelemetry initialized: exporting to ${endpoint}`);
88
+ // Graceful shutdown - flush spans before exit
89
+ const shutdown = (signal) => {
90
+ console.log(`[${serviceName}] Received ${signal}, shutting down OpenTelemetry...`);
91
+ sdk
92
+ .shutdown()
93
+ .then(() => console.log(`[${serviceName}] OpenTelemetry shutdown complete`))
94
+ .catch((err) => console.error(`[${serviceName}] OpenTelemetry shutdown error`, err));
95
+ // Don't call process.exit() - let the main process handle termination
96
+ };
97
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
98
+ process.on("SIGINT", () => shutdown("SIGINT"));
99
+ }
100
+ // Run bootstrap immediately on import
101
+ bootstrap();
@@ -0,0 +1,69 @@
1
+ /**
2
+ * OpenTelemetry instrumentation for ACCESS-CI MCP servers.
3
+ *
4
+ * Provides distributed tracing with OTLP export to Honeycomb.
5
+ */
6
+ import { Span, SpanStatusCode, context as otelContext } from "@opentelemetry/api";
7
+ export interface TelemetryConfig {
8
+ serviceName: string;
9
+ serviceVersion: string;
10
+ environment?: string;
11
+ }
12
+ /**
13
+ * Initialize OpenTelemetry SDK with OTLP export to Honeycomb.
14
+ *
15
+ * Environment variables:
16
+ * - OTEL_ENABLED: Set to "false" to disable telemetry (default: true)
17
+ * - OTEL_EXPORTER_OTLP_ENDPOINT: OTLP endpoint URL (e.g., https://api.honeycomb.io)
18
+ * - OTEL_EXPORTER_OTLP_HEADERS: Headers for auth (e.g., "x-honeycomb-team=your-api-key")
19
+ * - HONEYCOMB_DATASET: Dataset name (default: "access-ci")
20
+ */
21
+ export declare function initTelemetry(config: TelemetryConfig): void;
22
+ /**
23
+ * Shutdown OpenTelemetry SDK and flush pending spans.
24
+ */
25
+ export declare function shutdownTelemetry(): Promise<void>;
26
+ /**
27
+ * Get a tracer for creating custom spans.
28
+ */
29
+ export declare function getTracer(name: string): import("@opentelemetry/api").Tracer;
30
+ /**
31
+ * Get the current active span.
32
+ */
33
+ export declare function getCurrentSpan(): Span | undefined;
34
+ /**
35
+ * Set an attribute on the current span.
36
+ */
37
+ export declare function setSpanAttribute(key: string, value: string | number | boolean): void;
38
+ /**
39
+ * Add an event to the current span.
40
+ */
41
+ export declare function addSpanEvent(name: string, attributes?: Record<string, string | number | boolean>): void;
42
+ /**
43
+ * Record an error on the current span.
44
+ */
45
+ export declare function recordSpanError(error: Error): void;
46
+ /**
47
+ * Create a child span for tracing a specific operation.
48
+ *
49
+ * @example
50
+ * await withSpan("tool.get_events", { "tool.args": JSON.stringify(args) }, async (span) => {
51
+ * const result = await fetchEvents(args);
52
+ * span.setAttribute("tool.result_count", result.length);
53
+ * return result;
54
+ * });
55
+ */
56
+ export declare function withSpan<T>(name: string, attributes: Record<string, string | number | boolean>, fn: (span: Span) => Promise<T>): Promise<T>;
57
+ /**
58
+ * Trace an MCP tool call following OpenTelemetry MCP semantic conventions.
59
+ * https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/
60
+ *
61
+ * @example
62
+ * const result = await traceMcpToolCall("get_events", args, async (span) => {
63
+ * const events = await fetchEvents(args);
64
+ * span.setAttribute("mcp.result.count", events.length);
65
+ * return events;
66
+ * });
67
+ */
68
+ export declare function traceMcpToolCall<T>(toolName: string, args: Record<string, unknown>, fn: (span: Span) => Promise<T>): Promise<T>;
69
+ export { Span, SpanStatusCode, otelContext };
@@ -0,0 +1,219 @@
1
+ /**
2
+ * OpenTelemetry instrumentation for ACCESS-CI MCP servers.
3
+ *
4
+ * Provides distributed tracing with OTLP export to Honeycomb.
5
+ */
6
+ import { NodeSDK } from "@opentelemetry/sdk-node";
7
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
8
+ import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
9
+ import { ExpressInstrumentation } from "@opentelemetry/instrumentation-express";
10
+ import { resourceFromAttributes } from "@opentelemetry/resources";
11
+ import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION, SEMRESATTRS_DEPLOYMENT_ENVIRONMENT, } from "@opentelemetry/semantic-conventions";
12
+ import { trace, SpanStatusCode, context as otelContext } from "@opentelemetry/api";
13
+ let sdk = null;
14
+ /**
15
+ * Initialize OpenTelemetry SDK with OTLP export to Honeycomb.
16
+ *
17
+ * Environment variables:
18
+ * - OTEL_ENABLED: Set to "false" to disable telemetry (default: true)
19
+ * - OTEL_EXPORTER_OTLP_ENDPOINT: OTLP endpoint URL (e.g., https://api.honeycomb.io)
20
+ * - OTEL_EXPORTER_OTLP_HEADERS: Headers for auth (e.g., "x-honeycomb-team=your-api-key")
21
+ * - HONEYCOMB_DATASET: Dataset name (default: "access-ci")
22
+ */
23
+ export function initTelemetry(config) {
24
+ // Only initialize once
25
+ if (sdk !== null) {
26
+ return;
27
+ }
28
+ // Check if telemetry is disabled
29
+ if (process.env.OTEL_ENABLED === "false") {
30
+ console.log(`[${config.serviceName}] OpenTelemetry disabled via OTEL_ENABLED=false`);
31
+ return;
32
+ }
33
+ const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
34
+ if (!endpoint) {
35
+ console.log(`[${config.serviceName}] OpenTelemetry disabled: no OTEL_EXPORTER_OTLP_ENDPOINT`);
36
+ return;
37
+ }
38
+ // Parse headers from environment
39
+ const headersStr = process.env.OTEL_EXPORTER_OTLP_HEADERS || "";
40
+ const headers = {};
41
+ headersStr.split(",").forEach((pair) => {
42
+ const [key, value] = pair.split("=");
43
+ if (key && value) {
44
+ headers[key.trim()] = value.trim();
45
+ }
46
+ });
47
+ // Set Honeycomb dataset header to group all services together
48
+ const dataset = process.env.HONEYCOMB_DATASET || "access-ci";
49
+ headers["x-honeycomb-dataset"] = dataset;
50
+ const traceExporter = new OTLPTraceExporter({
51
+ url: `${endpoint}/v1/traces`,
52
+ headers,
53
+ });
54
+ // Use a common service.name for the Honeycomb dataset, with component to distinguish servers
55
+ const datasetName = process.env.HONEYCOMB_DATASET || "access-ci";
56
+ const resource = resourceFromAttributes({
57
+ [SEMRESATTRS_SERVICE_NAME]: datasetName,
58
+ [SEMRESATTRS_SERVICE_VERSION]: config.serviceVersion,
59
+ [SEMRESATTRS_DEPLOYMENT_ENVIRONMENT]: config.environment || process.env.ENVIRONMENT || "local",
60
+ "service.component": config.serviceName,
61
+ });
62
+ sdk = new NodeSDK({
63
+ resource,
64
+ traceExporter,
65
+ instrumentations: [new HttpInstrumentation(), new ExpressInstrumentation()],
66
+ });
67
+ sdk.start();
68
+ console.log(`[${config.serviceName}] OpenTelemetry initialized: exporting to ${endpoint}`);
69
+ // Graceful shutdown
70
+ process.on("SIGTERM", () => {
71
+ sdk
72
+ ?.shutdown()
73
+ .then(() => console.log(`[${config.serviceName}] OpenTelemetry shutdown complete`))
74
+ .catch((err) => console.error(`[${config.serviceName}] OpenTelemetry shutdown error`, err));
75
+ });
76
+ }
77
+ /**
78
+ * Shutdown OpenTelemetry SDK and flush pending spans.
79
+ */
80
+ export async function shutdownTelemetry() {
81
+ if (sdk) {
82
+ await sdk.shutdown();
83
+ sdk = null;
84
+ }
85
+ }
86
+ /**
87
+ * Get a tracer for creating custom spans.
88
+ */
89
+ export function getTracer(name) {
90
+ return trace.getTracer(name);
91
+ }
92
+ /**
93
+ * Get the current active span.
94
+ */
95
+ export function getCurrentSpan() {
96
+ return trace.getActiveSpan();
97
+ }
98
+ /**
99
+ * Set an attribute on the current span.
100
+ */
101
+ export function setSpanAttribute(key, value) {
102
+ const span = trace.getActiveSpan();
103
+ if (span) {
104
+ span.setAttribute(key, value);
105
+ }
106
+ }
107
+ /**
108
+ * Add an event to the current span.
109
+ */
110
+ export function addSpanEvent(name, attributes) {
111
+ const span = trace.getActiveSpan();
112
+ if (span) {
113
+ span.addEvent(name, attributes);
114
+ }
115
+ }
116
+ /**
117
+ * Record an error on the current span.
118
+ */
119
+ export function recordSpanError(error) {
120
+ const span = trace.getActiveSpan();
121
+ if (span) {
122
+ span.recordException(error);
123
+ span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
124
+ }
125
+ }
126
+ /**
127
+ * Create a child span for tracing a specific operation.
128
+ *
129
+ * @example
130
+ * await withSpan("tool.get_events", { "tool.args": JSON.stringify(args) }, async (span) => {
131
+ * const result = await fetchEvents(args);
132
+ * span.setAttribute("tool.result_count", result.length);
133
+ * return result;
134
+ * });
135
+ */
136
+ export async function withSpan(name, attributes, fn) {
137
+ const tracer = getTracer("access-mcp");
138
+ return tracer.startActiveSpan(name, { attributes }, async (span) => {
139
+ try {
140
+ const result = await fn(span);
141
+ span.setStatus({ code: SpanStatusCode.OK });
142
+ return result;
143
+ }
144
+ catch (error) {
145
+ if (error instanceof Error) {
146
+ span.recordException(error);
147
+ span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
148
+ }
149
+ throw error;
150
+ }
151
+ finally {
152
+ span.end();
153
+ }
154
+ });
155
+ }
156
+ /**
157
+ * Trace an MCP tool call following OpenTelemetry MCP semantic conventions.
158
+ * https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/
159
+ *
160
+ * @example
161
+ * const result = await traceMcpToolCall("get_events", args, async (span) => {
162
+ * const events = await fetchEvents(args);
163
+ * span.setAttribute("mcp.result.count", events.length);
164
+ * return events;
165
+ * });
166
+ */
167
+ export async function traceMcpToolCall(toolName, args, fn) {
168
+ const tracer = getTracer("access-mcp.tools");
169
+ // Follow MCP semantic conventions: "tools/call {tool_name}"
170
+ const spanName = `tools/call ${toolName}`;
171
+ const attributes = {
172
+ "mcp.method.name": "tools/call",
173
+ "gen_ai.operation.name": "execute_tool",
174
+ "gen_ai.tool.name": toolName,
175
+ };
176
+ // Add safe argument attributes (redact sensitive values)
177
+ const safeArgs = redactSensitive(args);
178
+ attributes["mcp.tool.arguments"] = JSON.stringify(safeArgs);
179
+ return tracer.startActiveSpan(spanName, { attributes }, async (span) => {
180
+ try {
181
+ const result = await fn(span);
182
+ span.setStatus({ code: SpanStatusCode.OK });
183
+ return result;
184
+ }
185
+ catch (error) {
186
+ if (error instanceof Error) {
187
+ span.recordException(error);
188
+ span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
189
+ span.setAttribute("error.type", error.name || "Error");
190
+ }
191
+ throw error;
192
+ }
193
+ finally {
194
+ span.end();
195
+ }
196
+ });
197
+ }
198
+ /**
199
+ * Redact sensitive values from arguments before logging.
200
+ */
201
+ function redactSensitive(data) {
202
+ const sensitiveKeys = ["password", "token", "secret", "key", "auth", "credential", "api_key"];
203
+ const redacted = {};
204
+ for (const [key, value] of Object.entries(data)) {
205
+ const keyLower = key.toLowerCase();
206
+ if (sensitiveKeys.some((s) => keyLower.includes(s))) {
207
+ redacted[key] = "[REDACTED]";
208
+ }
209
+ else if (value && typeof value === "object" && !Array.isArray(value)) {
210
+ redacted[key] = redactSensitive(value);
211
+ }
212
+ else {
213
+ redacted[key] = value;
214
+ }
215
+ }
216
+ return redacted;
217
+ }
218
+ // Re-export for convenience
219
+ export { SpanStatusCode, otelContext };
package/dist/utils.js CHANGED
@@ -104,6 +104,14 @@ export const CommonNextSteps = {
104
104
  * ```
105
105
  */
106
106
  export async function resolveResourceId(input, searchFn) {
107
+ // Guard against undefined/null input
108
+ if (!input) {
109
+ return {
110
+ success: false,
111
+ error: "Resource ID is required",
112
+ suggestion: "Use search_resources to find valid resource names.",
113
+ };
114
+ }
107
115
  // If it already looks like a full resource ID (contains dots), return as-is
108
116
  if (input.includes(".")) {
109
117
  return { success: true, id: input };
package/package.json CHANGED
@@ -1,10 +1,20 @@
1
1
  {
2
2
  "name": "@access-mcp/shared",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Shared utilities for ACCESS-CI MCP servers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ },
13
+ "./telemetry-bootstrap": {
14
+ "import": "./dist/telemetry-bootstrap.js",
15
+ "types": "./dist/telemetry-bootstrap.d.ts"
16
+ }
17
+ },
8
18
  "files": [
9
19
  "dist/**/*",
10
20
  "README.md"
@@ -33,8 +43,17 @@
33
43
  },
34
44
  "dependencies": {
35
45
  "@modelcontextprotocol/sdk": "^1.16.0",
46
+ "@opentelemetry/api": "^1.9.0",
47
+ "@opentelemetry/exporter-trace-otlp-http": "^0.210.0",
48
+ "@opentelemetry/instrumentation-express": "^0.57.1",
49
+ "@opentelemetry/instrumentation-http": "^0.210.0",
50
+ "@opentelemetry/resources": "^2.4.0",
51
+ "@opentelemetry/sdk-node": "^0.210.0",
52
+ "@opentelemetry/sdk-trace-node": "^2.4.0",
53
+ "@opentelemetry/semantic-conventions": "^1.38.0",
36
54
  "axios": "^1.6.0",
37
55
  "express": "^5.1.0",
56
+ "router": "^2.2.0",
38
57
  "zod": "^3.22.0"
39
58
  },
40
59
  "devDependencies": {