@mcp-audit-gateway/core 0.1.0

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.
Files changed (145) hide show
  1. package/.github/workflows/npm-publish.yml +21 -0
  2. package/CONTRIBUTING.md +41 -0
  3. package/LICENSE +21 -0
  4. package/README.md +156 -0
  5. package/dist/attestation/audit-log.d.ts +25 -0
  6. package/dist/attestation/audit-log.d.ts.map +1 -0
  7. package/dist/attestation/audit-log.js +95 -0
  8. package/dist/attestation/audit-log.js.map +1 -0
  9. package/dist/attestation/bugfix.test.d.ts +2 -0
  10. package/dist/attestation/bugfix.test.d.ts.map +1 -0
  11. package/dist/attestation/bugfix.test.js +103 -0
  12. package/dist/attestation/bugfix.test.js.map +1 -0
  13. package/dist/attestation/chain.test.d.ts +2 -0
  14. package/dist/attestation/chain.test.d.ts.map +1 -0
  15. package/dist/attestation/chain.test.js +153 -0
  16. package/dist/attestation/chain.test.js.map +1 -0
  17. package/dist/attestation/keygen.d.ts +5 -0
  18. package/dist/attestation/keygen.d.ts.map +1 -0
  19. package/dist/attestation/keygen.js +12 -0
  20. package/dist/attestation/keygen.js.map +1 -0
  21. package/dist/attestation/signer.d.ts +25 -0
  22. package/dist/attestation/signer.d.ts.map +1 -0
  23. package/dist/attestation/signer.js +93 -0
  24. package/dist/attestation/signer.js.map +1 -0
  25. package/dist/attestation/signer.test.d.ts +2 -0
  26. package/dist/attestation/signer.test.d.ts.map +1 -0
  27. package/dist/attestation/signer.test.js +62 -0
  28. package/dist/attestation/signer.test.js.map +1 -0
  29. package/dist/attestation/verify.d.ts +26 -0
  30. package/dist/attestation/verify.d.ts.map +1 -0
  31. package/dist/attestation/verify.js +104 -0
  32. package/dist/attestation/verify.js.map +1 -0
  33. package/dist/attestation/verify.test.d.ts +2 -0
  34. package/dist/attestation/verify.test.d.ts.map +1 -0
  35. package/dist/attestation/verify.test.js +79 -0
  36. package/dist/attestation/verify.test.js.map +1 -0
  37. package/dist/cli.d.ts +3 -0
  38. package/dist/cli.d.ts.map +1 -0
  39. package/dist/cli.js +364 -0
  40. package/dist/cli.js.map +1 -0
  41. package/dist/index.d.ts +12 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +12 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/integration.test.d.ts +2 -0
  46. package/dist/integration.test.d.ts.map +1 -0
  47. package/dist/integration.test.js +88 -0
  48. package/dist/integration.test.js.map +1 -0
  49. package/dist/policy/engine.d.ts +24 -0
  50. package/dist/policy/engine.d.ts.map +1 -0
  51. package/dist/policy/engine.js +130 -0
  52. package/dist/policy/engine.js.map +1 -0
  53. package/dist/policy/engine.test.d.ts +2 -0
  54. package/dist/policy/engine.test.d.ts.map +1 -0
  55. package/dist/policy/engine.test.js +80 -0
  56. package/dist/policy/engine.test.js.map +1 -0
  57. package/dist/proxy/gateway.d.ts +54 -0
  58. package/dist/proxy/gateway.d.ts.map +1 -0
  59. package/dist/proxy/gateway.js +245 -0
  60. package/dist/proxy/gateway.js.map +1 -0
  61. package/dist/proxy/gateway.test.d.ts +2 -0
  62. package/dist/proxy/gateway.test.d.ts.map +1 -0
  63. package/dist/proxy/gateway.test.js +93 -0
  64. package/dist/proxy/gateway.test.js.map +1 -0
  65. package/dist/proxy/mcp-server-adapter.d.ts +21 -0
  66. package/dist/proxy/mcp-server-adapter.d.ts.map +1 -0
  67. package/dist/proxy/mcp-server-adapter.js +98 -0
  68. package/dist/proxy/mcp-server-adapter.js.map +1 -0
  69. package/dist/proxy/mcp-server-adapter.test.d.ts +2 -0
  70. package/dist/proxy/mcp-server-adapter.test.d.ts.map +1 -0
  71. package/dist/proxy/mcp-server-adapter.test.js +160 -0
  72. package/dist/proxy/mcp-server-adapter.test.js.map +1 -0
  73. package/dist/proxy/upstream-manager.d.ts +49 -0
  74. package/dist/proxy/upstream-manager.d.ts.map +1 -0
  75. package/dist/proxy/upstream-manager.js +246 -0
  76. package/dist/proxy/upstream-manager.js.map +1 -0
  77. package/dist/proxy/upstream-manager.test.d.ts +2 -0
  78. package/dist/proxy/upstream-manager.test.d.ts.map +1 -0
  79. package/dist/proxy/upstream-manager.test.js +413 -0
  80. package/dist/proxy/upstream-manager.test.js.map +1 -0
  81. package/dist/telemetry/logger.d.ts +26 -0
  82. package/dist/telemetry/logger.d.ts.map +1 -0
  83. package/dist/telemetry/logger.js +48 -0
  84. package/dist/telemetry/logger.js.map +1 -0
  85. package/dist/telemetry/logger.test.d.ts +2 -0
  86. package/dist/telemetry/logger.test.d.ts.map +1 -0
  87. package/dist/telemetry/logger.test.js +124 -0
  88. package/dist/telemetry/logger.test.js.map +1 -0
  89. package/dist/telemetry/metrics.d.ts +41 -0
  90. package/dist/telemetry/metrics.d.ts.map +1 -0
  91. package/dist/telemetry/metrics.js +64 -0
  92. package/dist/telemetry/metrics.js.map +1 -0
  93. package/dist/telemetry/metrics.test.d.ts +2 -0
  94. package/dist/telemetry/metrics.test.d.ts.map +1 -0
  95. package/dist/telemetry/metrics.test.js +93 -0
  96. package/dist/telemetry/metrics.test.js.map +1 -0
  97. package/dist/telemetry/tracer.d.ts +16 -0
  98. package/dist/telemetry/tracer.d.ts.map +1 -0
  99. package/dist/telemetry/tracer.js +71 -0
  100. package/dist/telemetry/tracer.js.map +1 -0
  101. package/dist/types.d.ts +477 -0
  102. package/dist/types.d.ts.map +1 -0
  103. package/dist/types.js +68 -0
  104. package/dist/types.js.map +1 -0
  105. package/dist/wrap/proxy.d.ts +2 -0
  106. package/dist/wrap/proxy.d.ts.map +1 -0
  107. package/dist/wrap/proxy.js +152 -0
  108. package/dist/wrap/proxy.js.map +1 -0
  109. package/dist/wrap/proxy.test.d.ts +2 -0
  110. package/dist/wrap/proxy.test.d.ts.map +1 -0
  111. package/dist/wrap/proxy.test.js +155 -0
  112. package/dist/wrap/proxy.test.js.map +1 -0
  113. package/examples/README.md +33 -0
  114. package/examples/claude-desktop.json +50 -0
  115. package/examples/multi-agent-production.json +72 -0
  116. package/gateway.config.example.json +61 -0
  117. package/package.json +49 -0
  118. package/src/attestation/audit-log.ts +126 -0
  119. package/src/attestation/bugfix.test.ts +113 -0
  120. package/src/attestation/chain.test.ts +188 -0
  121. package/src/attestation/keygen.ts +18 -0
  122. package/src/attestation/signer.test.ts +75 -0
  123. package/src/attestation/signer.ts +105 -0
  124. package/src/attestation/verify.test.ts +86 -0
  125. package/src/attestation/verify.ts +131 -0
  126. package/src/cli.ts +407 -0
  127. package/src/index.ts +21 -0
  128. package/src/integration.test.ts +108 -0
  129. package/src/policy/engine.test.ts +95 -0
  130. package/src/policy/engine.ts +162 -0
  131. package/src/proxy/gateway.test.ts +108 -0
  132. package/src/proxy/gateway.ts +328 -0
  133. package/src/proxy/mcp-server-adapter.test.ts +197 -0
  134. package/src/proxy/mcp-server-adapter.ts +140 -0
  135. package/src/proxy/upstream-manager.test.ts +534 -0
  136. package/src/proxy/upstream-manager.ts +326 -0
  137. package/src/telemetry/logger.test.ts +139 -0
  138. package/src/telemetry/logger.ts +72 -0
  139. package/src/telemetry/metrics.test.ts +125 -0
  140. package/src/telemetry/metrics.ts +83 -0
  141. package/src/telemetry/tracer.ts +90 -0
  142. package/src/types.ts +113 -0
  143. package/src/wrap/proxy.test.ts +175 -0
  144. package/src/wrap/proxy.ts +192 -0
  145. package/tsconfig.json +18 -0
@@ -0,0 +1,328 @@
1
+ import type {
2
+ GatewayConfig,
3
+ UpstreamConfig,
4
+ UpstreamStatus,
5
+ ToolEntry,
6
+ AuditRecord,
7
+ } from "../types.js";
8
+ import { PolicyEngine } from "../policy/engine.js";
9
+ import { AuditLog } from "../attestation/audit-log.js";
10
+ import { createSigner } from "../attestation/signer.js";
11
+ import { GatewayTracer } from "../telemetry/tracer.js";
12
+ import { GatewayMetrics } from "../telemetry/metrics.js";
13
+ import { UpstreamManager } from "./upstream-manager.js";
14
+
15
+ export class Gateway {
16
+ private toolCatalog: Map<string, ToolEntry> = new Map();
17
+ private manualStatuses?: Map<string, UpstreamStatus>;
18
+ private policyEngine: PolicyEngine;
19
+ private auditLog: AuditLog;
20
+ private tracer: GatewayTracer;
21
+ private metrics: GatewayMetrics;
22
+ private upstreamManager: UpstreamManager;
23
+
24
+ constructor(private config: GatewayConfig) {
25
+ this.policyEngine = new PolicyEngine(
26
+ config.policy.defaultEffect,
27
+ config.policy.rules,
28
+ );
29
+
30
+ const signer = createSigner(config.attestation);
31
+ this.auditLog = new AuditLog(
32
+ config.auditLog.path,
33
+ signer,
34
+ config.auditLog.rotateAfterMb * 1024 * 1024,
35
+ );
36
+
37
+ this.tracer = new GatewayTracer(config.telemetry);
38
+ this.metrics = new GatewayMetrics(config.telemetry);
39
+ this.upstreamManager = new UpstreamManager();
40
+ }
41
+
42
+ async init(): Promise<void> {
43
+ await this.auditLog.init();
44
+ for (const upstream of this.config.upstreams) {
45
+ await this.connectUpstream(upstream);
46
+ }
47
+ }
48
+
49
+ private async connectUpstream(upstream: UpstreamConfig): Promise<void> {
50
+ try {
51
+ const conn = await this.upstreamManager.connect(upstream);
52
+ this.registerUpstreamTools(
53
+ upstream.name,
54
+ upstream.namespace,
55
+ conn.tools,
56
+ );
57
+ } catch (err) {
58
+ console.error(`Failed to connect to upstream ${upstream.name}: ${err}`);
59
+ }
60
+ }
61
+
62
+ async handleToolsList(
63
+ principal?: string,
64
+ _meta?: Record<string, unknown>,
65
+ ): Promise<{ tools: unknown[]; _meta?: Record<string, unknown> }> {
66
+ const allTools = Array.from(this.toolCatalog.values());
67
+ const filtered = this.policyEngine.filterTools(principal, allTools);
68
+
69
+ const tools = filtered.map((entry) => ({
70
+ name: entry.name,
71
+ description: entry.description,
72
+ inputSchema: entry.inputSchema,
73
+ annotations: entry.annotations,
74
+ _meta: {
75
+ "x-gateway-routing/v1": {
76
+ upstream: entry.upstream,
77
+ namespace: entry.namespace,
78
+ },
79
+ },
80
+ }));
81
+
82
+ return {
83
+ tools,
84
+ _meta: {
85
+ "x-gateway-routing/v1": {
86
+ filteringApplied: true,
87
+ identityResolved: principal ?? "anonymous",
88
+ totalToolsPreFilter: this.toolCatalog.size,
89
+ totalToolsPostFilter: filtered.length,
90
+ },
91
+ },
92
+ };
93
+ }
94
+
95
+ async handleToolsCall(
96
+ toolName: string,
97
+ args: Record<string, unknown>,
98
+ principal?: string,
99
+ traceContext?: { traceparent?: string; tracestate?: string },
100
+ ): Promise<{ result: unknown; auditRecord: AuditRecord }> {
101
+ const startTime = Date.now();
102
+ const tool = this.toolCatalog.get(toolName);
103
+
104
+ if (!tool) {
105
+ const record = await this.auditLog.record("tools/call", {
106
+ toolName,
107
+ principal,
108
+ durationMs: Date.now() - startTime,
109
+ success: false,
110
+ errorCode: -32602,
111
+ });
112
+ throw new ToolCallError(-32602, `Unknown tool: ${toolName}`, record);
113
+ }
114
+
115
+ const decision = this.policyEngine.evaluate(principal, tool);
116
+ if (!decision.allowed) {
117
+ this.metrics.recordPolicyDenial({
118
+ principal: principal ?? "anonymous",
119
+ tool: toolName,
120
+ reason: decision.reason ?? "unauthorized",
121
+ });
122
+ const record = await this.auditLog.record("tools/call", {
123
+ toolName,
124
+ namespace: tool.namespace,
125
+ upstream: tool.upstream,
126
+ principal,
127
+ durationMs: Date.now() - startTime,
128
+ success: false,
129
+ errorCode: -32603,
130
+ });
131
+ throw new ToolCallError(
132
+ -32603,
133
+ decision.reason ?? "unauthorized",
134
+ record,
135
+ );
136
+ }
137
+
138
+ const span = this.tracer.startRouteSpan(
139
+ "tools/call",
140
+ toolName,
141
+ tool.upstream,
142
+ traceContext,
143
+ );
144
+
145
+ try {
146
+ this.policyEngine.recordInvocation(principal, tool);
147
+
148
+ const result = await this.upstreamManager.callTool(
149
+ tool.upstream,
150
+ tool.originalName,
151
+ args,
152
+ );
153
+ const durationMs = Date.now() - startTime;
154
+
155
+ this.tracer.recordDuration(span, durationMs);
156
+ this.tracer.endSpan(span, true);
157
+
158
+ this.metrics.recordToolCall({
159
+ namespace: tool.namespace,
160
+ tool: toolName,
161
+ principal: principal ?? "anonymous",
162
+ success: "true",
163
+ });
164
+ this.metrics.recordDuration(durationMs, {
165
+ namespace: tool.namespace,
166
+ upstream: tool.upstream,
167
+ });
168
+
169
+ const record = await this.safeAuditRecord("tools/call", {
170
+ toolName,
171
+ namespace: tool.namespace,
172
+ upstream: tool.upstream,
173
+ principal,
174
+ durationMs,
175
+ success: true,
176
+ });
177
+
178
+ return { result, auditRecord: record };
179
+ } catch (err) {
180
+ const durationMs = Date.now() - startTime;
181
+ this.tracer.recordDuration(span, durationMs);
182
+ this.tracer.endSpan(span, false);
183
+
184
+ this.metrics.recordToolCall({
185
+ namespace: tool.namespace,
186
+ tool: toolName,
187
+ principal: principal ?? "anonymous",
188
+ success: "false",
189
+ });
190
+ this.metrics.recordDuration(durationMs, {
191
+ namespace: tool.namespace,
192
+ upstream: tool.upstream,
193
+ });
194
+
195
+ if (err instanceof ToolCallError) throw err;
196
+
197
+ const record = await this.safeAuditRecord("tools/call", {
198
+ toolName,
199
+ namespace: tool.namespace,
200
+ upstream: tool.upstream,
201
+ principal,
202
+ durationMs,
203
+ success: false,
204
+ errorCode: -32603,
205
+ });
206
+ throw new ToolCallError(-32603, "Upstream server error", record);
207
+ }
208
+ }
209
+
210
+ getStatus(): {
211
+ gateway: { status: string; version: string };
212
+ upstreams: UpstreamStatus[];
213
+ aggregate: {
214
+ totalUpstreams: number;
215
+ healthyUpstreams: number;
216
+ degradedUpstreams: number;
217
+ unavailableUpstreams: number;
218
+ totalTools: number;
219
+ };
220
+ } {
221
+ const managed = this.upstreamManager.getAllStatuses();
222
+ const manual = this.manualStatuses ? Array.from(this.manualStatuses.values()) : [];
223
+ const upstreams = managed.length > 0 ? managed : manual;
224
+ return {
225
+ gateway: {
226
+ status: "healthy",
227
+ version: this.config.version,
228
+ },
229
+ upstreams,
230
+ aggregate: {
231
+ totalUpstreams: upstreams.length,
232
+ healthyUpstreams: upstreams.filter((u) => u.status === "healthy").length,
233
+ degradedUpstreams: upstreams.filter((u) => u.status === "degraded").length,
234
+ unavailableUpstreams: upstreams.filter((u) => u.status === "unavailable").length,
235
+ totalTools: this.toolCatalog.size,
236
+ },
237
+ };
238
+ }
239
+
240
+ getServerDiscover(): Record<string, unknown> {
241
+ return {
242
+ name: this.config.name,
243
+ version: this.config.version,
244
+ protocol: "2025-03-26",
245
+ capabilities: {
246
+ tools: { listChanged: true },
247
+ gateway: {
248
+ namespacing: true,
249
+ filtering: true,
250
+ routing: true,
251
+ healthReporting: true,
252
+ attestation: this.config.attestation.enabled,
253
+ upstreamCount: this.config.upstreams.length,
254
+ },
255
+ },
256
+ extensions: ["x-gateway-routing/v1", "x-gateway-attestation/v1"],
257
+ };
258
+ }
259
+
260
+ registerUpstreamTools(
261
+ upstreamName: string,
262
+ namespace: string,
263
+ tools: Array<{
264
+ name: string;
265
+ description?: string;
266
+ inputSchema?: unknown;
267
+ annotations?: Record<string, unknown>;
268
+ }>,
269
+ ): void {
270
+ for (const tool of tools) {
271
+ const namespacedName = `${namespace}/${tool.name}`;
272
+ const entry: ToolEntry = {
273
+ name: namespacedName,
274
+ originalName: tool.name,
275
+ namespace,
276
+ upstream: upstreamName,
277
+ description: tool.description,
278
+ inputSchema: tool.inputSchema,
279
+ annotations: tool.annotations,
280
+ };
281
+ this.toolCatalog.set(namespacedName, entry);
282
+ }
283
+
284
+ if (!this.manualStatuses) this.manualStatuses = new Map();
285
+ this.manualStatuses.set(upstreamName, {
286
+ name: upstreamName,
287
+ namespace,
288
+ status: "healthy",
289
+ toolCount: tools.length,
290
+ lastSuccessfulContact: new Date().toISOString(),
291
+ });
292
+
293
+ this.metrics.setUpstreamStatus(upstreamName, "healthy", 1);
294
+ }
295
+
296
+ private async safeAuditRecord(
297
+ method: string,
298
+ opts: Parameters<AuditLog["record"]>[1],
299
+ ): Promise<AuditRecord> {
300
+ try {
301
+ return await this.auditLog.record(method, opts);
302
+ } catch (err) {
303
+ console.error(`Audit log write failed: ${err}`);
304
+ return {
305
+ id: "unrecorded",
306
+ timestamp: new Date().toISOString(),
307
+ method,
308
+ ...opts,
309
+ attestation: undefined,
310
+ };
311
+ }
312
+ }
313
+
314
+ async shutdown(): Promise<void> {
315
+ await this.upstreamManager.disconnectAll();
316
+ }
317
+ }
318
+
319
+ export class ToolCallError extends Error {
320
+ constructor(
321
+ public code: number,
322
+ message: string,
323
+ public auditRecord: AuditRecord,
324
+ ) {
325
+ super(message);
326
+ this.name = "ToolCallError";
327
+ }
328
+ }
@@ -0,0 +1,197 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
4
+ import {
5
+ ListToolsResultSchema,
6
+ CompatibilityCallToolResultSchema,
7
+ } from "@modelcontextprotocol/sdk/types.js";
8
+ import { Gateway } from "./gateway.js";
9
+ import { McpServerAdapter } from "./mcp-server-adapter.js";
10
+ import type { GatewayConfig } from "../types.js";
11
+ import { unlink } from "node:fs/promises";
12
+
13
+ const AUDIT_PATH = "/tmp/mcp-adapter-test-audit.jsonl";
14
+
15
+ const testConfig: GatewayConfig = {
16
+ name: "adapter-test-gateway",
17
+ version: "0.2.0",
18
+ listen: { transport: "streamable-http", port: 3200, host: "127.0.0.1" },
19
+ upstreams: [
20
+ {
21
+ name: "mock-server",
22
+ namespace: "mock",
23
+ transport: { type: "stdio", command: "echo", args: [] },
24
+ },
25
+ ],
26
+ policy: {
27
+ defaultEffect: "allow",
28
+ rules: [
29
+ {
30
+ effect: "deny",
31
+ principals: ["agent:restricted"],
32
+ tools: ["mock/secret_tool"],
33
+ },
34
+ ],
35
+ },
36
+ attestation: {
37
+ enabled: true,
38
+ algorithm: "hmac-sha256",
39
+ secret: "b".repeat(64),
40
+ includeParams: false,
41
+ includeResult: false,
42
+ },
43
+ telemetry: { enabled: false, serviceName: "test", sampleRate: 0 },
44
+ auditLog: { enabled: true, path: AUDIT_PATH, rotateAfterMb: 10 },
45
+ };
46
+
47
+ describe("McpServerAdapter", () => {
48
+ let gateway: Gateway;
49
+ let adapter: McpServerAdapter;
50
+ let client: Client;
51
+
52
+ beforeEach(async () => {
53
+ gateway = new Gateway(testConfig);
54
+ await gateway.init();
55
+
56
+ // Register tools manually (simulating upstream discovery)
57
+ gateway.registerUpstreamTools("mock-server", "mock", [
58
+ {
59
+ name: "greet",
60
+ description: "Greets a user",
61
+ inputSchema: { type: "object", properties: { name: { type: "string" } } },
62
+ },
63
+ {
64
+ name: "secret_tool",
65
+ description: "A restricted tool",
66
+ inputSchema: { type: "object" },
67
+ },
68
+ ]);
69
+
70
+ adapter = new McpServerAdapter(gateway, {
71
+ name: "adapter-test-gateway",
72
+ version: "0.2.0",
73
+ resolvePrincipal: (meta) => meta?.requestIdentity as string | undefined,
74
+ });
75
+
76
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
77
+ client = new Client(
78
+ { name: "test-client", version: "1.0.0" },
79
+ { capabilities: {} },
80
+ );
81
+
82
+ await adapter.connect(serverTransport);
83
+ await client.connect(clientTransport);
84
+ });
85
+
86
+ afterEach(async () => {
87
+ await client.close();
88
+ await adapter.close();
89
+ try {
90
+ await unlink(AUDIT_PATH);
91
+ } catch {
92
+ // file may not exist
93
+ }
94
+ });
95
+
96
+ describe("tools/list", () => {
97
+ it("lists tools with namespace prefixes via the SDK client", async () => {
98
+ const result = await client.listTools();
99
+
100
+ expect(result.tools.length).toBe(2);
101
+ const names = result.tools.map((t) => t.name);
102
+ expect(names).toContain("mock/greet");
103
+ expect(names).toContain("mock/secret_tool");
104
+ });
105
+
106
+ it("includes tool descriptions through the adapter", async () => {
107
+ const result = await client.listTools();
108
+
109
+ const greet = result.tools.find((t) => t.name === "mock/greet");
110
+ expect(greet).toBeDefined();
111
+ expect(greet!.description).toBe("Greets a user");
112
+ });
113
+
114
+ it("applies policy filtering when requestIdentity is provided", async () => {
115
+ // Use a custom request with _meta to pass identity
116
+ const result = await client.request(
117
+ {
118
+ method: "tools/list",
119
+ params: {
120
+ _meta: { requestIdentity: "agent:restricted" },
121
+ },
122
+ },
123
+ ListToolsResultSchema,
124
+ );
125
+
126
+ const tools = result.tools;
127
+ const names = tools.map((t) => t.name);
128
+ expect(names).toContain("mock/greet");
129
+ expect(names).not.toContain("mock/secret_tool");
130
+ });
131
+ });
132
+
133
+ describe("tools/call", () => {
134
+ it("returns attestation metadata in tool call responses", async () => {
135
+ // Call a tool that will be denied by policy, which produces attestation
136
+ const result = await client.request(
137
+ {
138
+ method: "tools/call",
139
+ params: {
140
+ name: "mock/secret_tool",
141
+ arguments: {},
142
+ _meta: { requestIdentity: "agent:restricted" },
143
+ },
144
+ },
145
+ CompatibilityCallToolResultSchema,
146
+ );
147
+
148
+ // Should get an error response with attestation metadata
149
+ const meta = (result as any)._meta;
150
+ expect(meta).toBeDefined();
151
+ expect(meta["x-gateway-attestation/v1"]).toBeDefined();
152
+ expect(meta["x-gateway-attestation/v1"].auditId).toBeDefined();
153
+ expect(meta["x-gateway-attestation/v1"].attestation).toBeDefined();
154
+ expect(meta["x-gateway-attestation/v1"].timestamp).toBeDefined();
155
+
156
+ // Should be flagged as an error
157
+ expect((result as any).isError).toBe(true);
158
+ });
159
+
160
+ it("returns error content for unknown tools with attestation", async () => {
161
+ const result = await client.request(
162
+ {
163
+ method: "tools/call",
164
+ params: {
165
+ name: "mock/nonexistent",
166
+ arguments: {},
167
+ },
168
+ },
169
+ CompatibilityCallToolResultSchema,
170
+ );
171
+
172
+ expect((result as any).isError).toBe(true);
173
+ const content = (result as any).content;
174
+ expect(content).toHaveLength(1);
175
+ expect(content[0].type).toBe("text");
176
+ expect(content[0].text).toContain("Unknown tool");
177
+
178
+ // Attestation metadata should still be present
179
+ const meta = (result as any)._meta;
180
+ expect(meta["x-gateway-attestation/v1"].auditId).toBeDefined();
181
+ });
182
+ });
183
+
184
+ describe("server capabilities", () => {
185
+ it("advertises tools capability", () => {
186
+ const caps = client.getServerCapabilities();
187
+ expect(caps?.tools).toBeDefined();
188
+ });
189
+
190
+ it("advertises gateway extension in experimental capabilities", () => {
191
+ const caps = client.getServerCapabilities();
192
+ expect(caps?.experimental).toBeDefined();
193
+ expect((caps?.experimental as any)["x-gateway-routing/v1"]).toBeDefined();
194
+ expect((caps?.experimental as any)["x-gateway-attestation/v1"]).toBeDefined();
195
+ });
196
+ });
197
+ });
@@ -0,0 +1,140 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import {
3
+ ListToolsRequestSchema,
4
+ CallToolRequestSchema,
5
+ } from "@modelcontextprotocol/sdk/types.js";
6
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
7
+ import { Gateway, ToolCallError } from "./gateway.js";
8
+
9
+ export interface McpServerAdapterOptions {
10
+ name: string;
11
+ version: string;
12
+ resolvePrincipal?: (meta?: Record<string, unknown>) => string | undefined;
13
+ }
14
+
15
+ export class McpServerAdapter {
16
+ private server: Server;
17
+ private gateway: Gateway;
18
+ private resolvePrincipal?: (meta?: Record<string, unknown>) => string | undefined;
19
+
20
+ constructor(gateway: Gateway, options: McpServerAdapterOptions) {
21
+ this.gateway = gateway;
22
+ this.resolvePrincipal = options.resolvePrincipal;
23
+
24
+ const discover = gateway.getServerDiscover();
25
+ const gatewayCapabilities = (discover.capabilities as Record<string, unknown>)?.gateway;
26
+
27
+ this.server = new Server(
28
+ { name: options.name, version: options.version },
29
+ {
30
+ capabilities: {
31
+ tools: { listChanged: true },
32
+ experimental: {
33
+ "x-gateway-routing/v1": gatewayCapabilities ?? {},
34
+ "x-gateway-attestation/v1": { enabled: true },
35
+ },
36
+ },
37
+ },
38
+ );
39
+
40
+ this.registerHandlers();
41
+ }
42
+
43
+ async connect(transport: Transport): Promise<void> {
44
+ await this.server.connect(transport);
45
+ }
46
+
47
+ async close(): Promise<void> {
48
+ await this.server.close();
49
+ }
50
+
51
+ getServer(): Server {
52
+ return this.server;
53
+ }
54
+
55
+ private registerHandlers(): void {
56
+ this.server.setRequestHandler(
57
+ ListToolsRequestSchema,
58
+ async (request) => {
59
+ const principal = this.extractPrincipal(request.params?._meta);
60
+ const listResult = await this.gateway.handleToolsList(principal);
61
+
62
+ return {
63
+ tools: listResult.tools.map((tool: any) => ({
64
+ name: tool.name,
65
+ description: tool.description,
66
+ inputSchema: tool.inputSchema ?? { type: "object" as const },
67
+ annotations: tool.annotations,
68
+ })),
69
+ _meta: listResult._meta,
70
+ };
71
+ },
72
+ );
73
+
74
+ this.server.setRequestHandler(
75
+ CallToolRequestSchema,
76
+ async (request) => {
77
+ const principal = this.extractPrincipal(request.params?._meta);
78
+ const traceContext = this.extractTraceContext(request.params?._meta);
79
+ const toolName = request.params.name;
80
+ const args = (request.params.arguments ?? {}) as Record<string, unknown>;
81
+
82
+ try {
83
+ const { result, auditRecord } = await this.gateway.handleToolsCall(
84
+ toolName,
85
+ args,
86
+ principal,
87
+ traceContext,
88
+ );
89
+
90
+ const upstreamResult = result as {
91
+ content?: Array<{ type: string; text?: string; [key: string]: unknown }>;
92
+ isError?: boolean;
93
+ };
94
+
95
+ return {
96
+ content: upstreamResult?.content ?? [{ type: "text" as const, text: JSON.stringify(result) }],
97
+ isError: upstreamResult?.isError,
98
+ _meta: {
99
+ "x-gateway-attestation/v1": {
100
+ auditId: auditRecord.id,
101
+ attestation: auditRecord.attestation,
102
+ timestamp: auditRecord.timestamp,
103
+ },
104
+ },
105
+ };
106
+ } catch (err) {
107
+ if (err instanceof ToolCallError) {
108
+ return {
109
+ content: [{ type: "text" as const, text: err.message }],
110
+ isError: true,
111
+ _meta: {
112
+ "x-gateway-attestation/v1": {
113
+ auditId: err.auditRecord.id,
114
+ attestation: err.auditRecord.attestation,
115
+ timestamp: err.auditRecord.timestamp,
116
+ },
117
+ },
118
+ };
119
+ }
120
+ throw err;
121
+ }
122
+ },
123
+ );
124
+ }
125
+
126
+ private extractPrincipal(meta?: Record<string, unknown>): string | undefined {
127
+ if (this.resolvePrincipal) {
128
+ return this.resolvePrincipal(meta);
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ private extractTraceContext(
134
+ meta?: Record<string, unknown>,
135
+ ): { traceparent?: string; tracestate?: string } | undefined {
136
+ if (!meta) return undefined;
137
+ const tc = meta.traceContext as { traceparent?: string; tracestate?: string } | undefined;
138
+ return tc;
139
+ }
140
+ }