@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.
- package/dist/__tests__/base-server.test.js +316 -3
- package/dist/base-server.d.ts +54 -2
- package/dist/base-server.js +119 -23
- package/dist/drupal-auth.d.ts +11 -1
- package/dist/drupal-auth.js +25 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/telemetry-bootstrap.d.ts +13 -0
- package/dist/telemetry-bootstrap.js +101 -0
- package/dist/telemetry.d.ts +69 -0
- package/dist/telemetry.js +219 -0
- package/dist/utils.js +8 -0
- package/package.json +20 -1
|
@@ -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(
|
|
76
|
-
expect(data.tools
|
|
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
|
+
});
|
package/dist/base-server.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/dist/base-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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}`);
|
package/dist/drupal-auth.d.ts
CHANGED
|
@@ -16,7 +16,17 @@ export declare class DrupalAuthProvider {
|
|
|
16
16
|
private userUuid?;
|
|
17
17
|
private httpClient;
|
|
18
18
|
private isAuthenticated;
|
|
19
|
-
|
|
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
|
*/
|
package/dist/drupal-auth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
package/dist/index.js
CHANGED
|
@@ -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.
|
|
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": {
|