@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,326 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import type { UpstreamConfig, UpstreamStatus } from "../types.js";
5
+
6
+ export interface UpstreamConnection {
7
+ config: UpstreamConfig;
8
+ client: Client;
9
+ status: UpstreamStatus;
10
+ tools: Array<{
11
+ name: string;
12
+ description?: string;
13
+ inputSchema?: unknown;
14
+ annotations?: Record<string, unknown>;
15
+ }>;
16
+ consecutiveFailures: number;
17
+ reconnectAttempts: number;
18
+ reconnectTimer?: ReturnType<typeof setTimeout>;
19
+ }
20
+
21
+ export class UpstreamManager {
22
+ private connections: Map<string, UpstreamConnection> = new Map();
23
+ private healthCheckInterval?: ReturnType<typeof setInterval>;
24
+ private static readonly MAX_CONSECUTIVE_FAILURES = 3;
25
+ private static readonly DEFAULT_TIMEOUT_MS = 10_000;
26
+ private static readonly MAX_BACKOFF_MS = 30_000;
27
+ private static readonly BASE_BACKOFF_MS = 1_000;
28
+
29
+ async connect(
30
+ upstream: UpstreamConfig,
31
+ timeoutMs: number = UpstreamManager.DEFAULT_TIMEOUT_MS,
32
+ ): Promise<UpstreamConnection> {
33
+ const client = new Client(
34
+ { name: "mcp-audit-gateway", version: "0.1.0" },
35
+ { capabilities: {} },
36
+ );
37
+
38
+ const transport = this.createTransport(upstream);
39
+
40
+ await this.withTimeout(client.connect(transport), timeoutMs, upstream.name);
41
+
42
+ let tools: UpstreamConnection["tools"];
43
+ try {
44
+ const toolsResult = await this.withTimeout(
45
+ client.listTools(),
46
+ timeoutMs,
47
+ upstream.name,
48
+ );
49
+ tools = toolsResult.tools.map((t) => ({
50
+ name: t.name,
51
+ description: t.description,
52
+ inputSchema: t.inputSchema,
53
+ annotations: t.annotations as Record<string, unknown> | undefined,
54
+ }));
55
+ } catch (err) {
56
+ try {
57
+ await client.close();
58
+ } catch {
59
+ // best effort
60
+ }
61
+ throw err;
62
+ }
63
+
64
+ const connection: UpstreamConnection = {
65
+ config: upstream,
66
+ client,
67
+ status: {
68
+ name: upstream.name,
69
+ namespace: upstream.namespace,
70
+ status: "healthy",
71
+ toolCount: tools.length,
72
+ lastSuccessfulContact: new Date().toISOString(),
73
+ },
74
+ tools,
75
+ consecutiveFailures: 0,
76
+ reconnectAttempts: 0,
77
+ };
78
+
79
+ this.connections.set(upstream.name, connection);
80
+ return connection;
81
+ }
82
+
83
+ async callTool(
84
+ upstreamName: string,
85
+ toolName: string,
86
+ args: Record<string, unknown>,
87
+ ): Promise<unknown> {
88
+ const conn = this.connections.get(upstreamName);
89
+ if (!conn) {
90
+ throw new Error(`Upstream not connected: ${upstreamName}`);
91
+ }
92
+
93
+ if (conn.status.status === "unavailable") {
94
+ throw new UpstreamUnavailableError(
95
+ upstreamName,
96
+ conn.status.unavailableReason ?? "Upstream is unavailable",
97
+ );
98
+ }
99
+
100
+ try {
101
+ const result = await conn.client.callTool({ name: toolName, arguments: args });
102
+ conn.status.lastSuccessfulContact = new Date().toISOString();
103
+ conn.status.status = "healthy";
104
+ conn.consecutiveFailures = 0;
105
+ return result;
106
+ } catch (err) {
107
+ conn.consecutiveFailures++;
108
+ if (conn.consecutiveFailures >= UpstreamManager.MAX_CONSECUTIVE_FAILURES) {
109
+ this.markUnavailable(conn, String(err));
110
+ } else {
111
+ conn.status.status = "degraded";
112
+ conn.status.degradedReason = String(err);
113
+ }
114
+ throw err;
115
+ }
116
+ }
117
+
118
+ startHealthChecks(intervalMs: number): void {
119
+ this.stopHealthChecks();
120
+ this.healthCheckInterval = setInterval(() => {
121
+ void this.runHealthChecks();
122
+ }, intervalMs);
123
+ }
124
+
125
+ stopHealthChecks(): void {
126
+ if (this.healthCheckInterval) {
127
+ clearInterval(this.healthCheckInterval);
128
+ this.healthCheckInterval = undefined;
129
+ }
130
+ }
131
+
132
+ async reconnect(upstreamName: string): Promise<void> {
133
+ const conn = this.connections.get(upstreamName);
134
+ if (!conn) {
135
+ throw new Error(`Upstream not found: ${upstreamName}`);
136
+ }
137
+
138
+ if (conn.reconnectTimer) {
139
+ clearTimeout(conn.reconnectTimer);
140
+ conn.reconnectTimer = undefined;
141
+ }
142
+
143
+ try {
144
+ try {
145
+ await conn.client.close();
146
+ } catch {
147
+ // best effort
148
+ }
149
+
150
+ const client = new Client(
151
+ { name: "mcp-audit-gateway", version: "0.1.0" },
152
+ { capabilities: {} },
153
+ );
154
+ const transport = this.createTransport(conn.config);
155
+ await this.withTimeout(
156
+ client.connect(transport),
157
+ UpstreamManager.DEFAULT_TIMEOUT_MS,
158
+ upstreamName,
159
+ );
160
+
161
+ const toolsResult = await this.withTimeout(
162
+ client.listTools(),
163
+ UpstreamManager.DEFAULT_TIMEOUT_MS,
164
+ upstreamName,
165
+ );
166
+ const tools = toolsResult.tools.map((t) => ({
167
+ name: t.name,
168
+ description: t.description,
169
+ inputSchema: t.inputSchema,
170
+ annotations: t.annotations as Record<string, unknown> | undefined,
171
+ }));
172
+
173
+ conn.client = client;
174
+ conn.tools = tools;
175
+ conn.status.status = "healthy";
176
+ conn.status.toolCount = tools.length;
177
+ conn.status.lastSuccessfulContact = new Date().toISOString();
178
+ conn.status.unavailableReason = undefined;
179
+ conn.status.degradedReason = undefined;
180
+ conn.consecutiveFailures = 0;
181
+ conn.reconnectAttempts = 0;
182
+ } catch (err) {
183
+ conn.reconnectAttempts++;
184
+ this.scheduleReconnect(conn);
185
+ throw err;
186
+ }
187
+ }
188
+
189
+ getConnection(name: string): UpstreamConnection | undefined {
190
+ return this.connections.get(name);
191
+ }
192
+
193
+ getAllStatuses(): UpstreamStatus[] {
194
+ return Array.from(this.connections.values()).map((c) => c.status);
195
+ }
196
+
197
+ async disconnectAll(): Promise<void> {
198
+ this.stopHealthChecks();
199
+ for (const conn of this.connections.values()) {
200
+ if (conn.reconnectTimer) {
201
+ clearTimeout(conn.reconnectTimer);
202
+ }
203
+ try {
204
+ await conn.client.close();
205
+ } catch {
206
+ // best effort
207
+ }
208
+ }
209
+ this.connections.clear();
210
+ }
211
+
212
+ private async runHealthChecks(): Promise<void> {
213
+ for (const conn of this.connections.values()) {
214
+ if (conn.status.status === "unavailable") {
215
+ continue;
216
+ }
217
+
218
+ try {
219
+ await conn.client.ping();
220
+ conn.status.lastSuccessfulContact = new Date().toISOString();
221
+ conn.consecutiveFailures = 0;
222
+ if (conn.status.status === "degraded") {
223
+ conn.status.status = "healthy";
224
+ conn.status.degradedReason = undefined;
225
+ }
226
+ } catch (err) {
227
+ conn.consecutiveFailures++;
228
+ if (conn.consecutiveFailures >= UpstreamManager.MAX_CONSECUTIVE_FAILURES) {
229
+ this.markUnavailable(conn, `Health check failed: ${err}`);
230
+ } else {
231
+ conn.status.status = "degraded";
232
+ conn.status.degradedReason = `Health check failed: ${err}`;
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ private markUnavailable(conn: UpstreamConnection, reason: string): void {
239
+ conn.status.status = "unavailable";
240
+ conn.status.unavailableReason = reason;
241
+ conn.status.degradedReason = undefined;
242
+ this.scheduleReconnect(conn);
243
+ }
244
+
245
+ private scheduleReconnect(conn: UpstreamConnection): void {
246
+ if (conn.reconnectTimer) {
247
+ clearTimeout(conn.reconnectTimer);
248
+ }
249
+
250
+ const backoffMs = this.calculateBackoff(conn.reconnectAttempts);
251
+ conn.reconnectTimer = setTimeout(() => {
252
+ void this.reconnect(conn.config.name).catch(() => {});
253
+ }, backoffMs);
254
+ }
255
+
256
+ private calculateBackoff(attempt: number): number {
257
+ const exponential = UpstreamManager.BASE_BACKOFF_MS * Math.pow(2, attempt);
258
+ return Math.min(exponential, UpstreamManager.MAX_BACKOFF_MS);
259
+ }
260
+
261
+ private async withTimeout<T>(
262
+ promise: Promise<T>,
263
+ timeoutMs: number,
264
+ upstreamName: string,
265
+ ): Promise<T> {
266
+ let timer: ReturnType<typeof setTimeout>;
267
+ const timeoutPromise = new Promise<never>((_, reject) => {
268
+ timer = setTimeout(() => {
269
+ reject(
270
+ new ConnectionTimeoutError(
271
+ upstreamName,
272
+ timeoutMs,
273
+ ),
274
+ );
275
+ }, timeoutMs);
276
+ });
277
+
278
+ try {
279
+ const result = await Promise.race([promise, timeoutPromise]);
280
+ clearTimeout(timer!);
281
+ return result;
282
+ } catch (err) {
283
+ clearTimeout(timer!);
284
+ throw err;
285
+ }
286
+ }
287
+
288
+ private createTransport(upstream: UpstreamConfig) {
289
+ if (upstream.transport.type === "stdio") {
290
+ return new StdioClientTransport({
291
+ command: upstream.transport.command,
292
+ args: upstream.transport.args,
293
+ env: upstream.transport.env,
294
+ });
295
+ }
296
+
297
+ return new StreamableHTTPClientTransport(
298
+ new URL(upstream.transport.url),
299
+ upstream.transport.headers ? { requestInit: { headers: upstream.transport.headers } } : undefined,
300
+ );
301
+ }
302
+ }
303
+
304
+ export class UpstreamUnavailableError extends Error {
305
+ constructor(
306
+ public readonly upstreamName: string,
307
+ public readonly reason: string,
308
+ ) {
309
+ super(
310
+ `Upstream "${upstreamName}" is unavailable: ${reason}`,
311
+ );
312
+ this.name = "UpstreamUnavailableError";
313
+ }
314
+ }
315
+
316
+ export class ConnectionTimeoutError extends Error {
317
+ constructor(
318
+ public readonly upstreamName: string,
319
+ public readonly timeoutMs: number,
320
+ ) {
321
+ super(
322
+ `Connection to upstream "${upstreamName}" timed out after ${timeoutMs}ms`,
323
+ );
324
+ this.name = "ConnectionTimeoutError";
325
+ }
326
+ }
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { Logger } from "./logger.js";
3
+ import type { LogLevel } from "./logger.js";
4
+
5
+ describe("Logger", () => {
6
+ let lines: string[];
7
+ let logger: Logger;
8
+
9
+ beforeEach(() => {
10
+ lines = [];
11
+ logger = new Logger({
12
+ component: "test",
13
+ level: "debug",
14
+ output: (line) => lines.push(line),
15
+ });
16
+ });
17
+
18
+ describe("structured output", () => {
19
+ it("outputs valid JSON lines", () => {
20
+ logger.info("hello world");
21
+ expect(lines).toHaveLength(1);
22
+ const parsed = JSON.parse(lines[0]);
23
+ expect(parsed).toHaveProperty("timestamp");
24
+ expect(parsed).toHaveProperty("level", "info");
25
+ expect(parsed).toHaveProperty("component", "test");
26
+ expect(parsed).toHaveProperty("message", "hello world");
27
+ });
28
+
29
+ it("includes ISO 8601 timestamp", () => {
30
+ logger.info("msg");
31
+ const parsed = JSON.parse(lines[0]);
32
+ expect(() => new Date(parsed.timestamp)).not.toThrow();
33
+ expect(parsed.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
34
+ });
35
+
36
+ it("includes arbitrary context fields", () => {
37
+ logger.info("tool call", { tool: "my_tool", durationMs: 42, upstream: "server-a" });
38
+ const parsed = JSON.parse(lines[0]);
39
+ expect(parsed.tool).toBe("my_tool");
40
+ expect(parsed.durationMs).toBe(42);
41
+ expect(parsed.upstream).toBe("server-a");
42
+ });
43
+ });
44
+
45
+ describe("log levels", () => {
46
+ it("outputs debug messages when level is debug", () => {
47
+ logger.debug("trace msg");
48
+ expect(lines).toHaveLength(1);
49
+ expect(JSON.parse(lines[0]).level).toBe("debug");
50
+ });
51
+
52
+ it("outputs info messages", () => {
53
+ logger.info("info msg");
54
+ expect(lines).toHaveLength(1);
55
+ expect(JSON.parse(lines[0]).level).toBe("info");
56
+ });
57
+
58
+ it("outputs warn messages", () => {
59
+ logger.warn("warning");
60
+ expect(lines).toHaveLength(1);
61
+ expect(JSON.parse(lines[0]).level).toBe("warn");
62
+ });
63
+
64
+ it("outputs error messages", () => {
65
+ logger.error("failure");
66
+ expect(lines).toHaveLength(1);
67
+ expect(JSON.parse(lines[0]).level).toBe("error");
68
+ });
69
+ });
70
+
71
+ describe("level filtering", () => {
72
+ it("suppresses debug when level is info", () => {
73
+ const infoLogger = new Logger({
74
+ component: "test",
75
+ level: "info",
76
+ output: (line) => lines.push(line),
77
+ });
78
+ infoLogger.debug("should not appear");
79
+ infoLogger.info("should appear");
80
+ expect(lines).toHaveLength(1);
81
+ expect(JSON.parse(lines[0]).message).toBe("should appear");
82
+ });
83
+
84
+ it("suppresses info and debug when level is warn", () => {
85
+ const warnLogger = new Logger({
86
+ component: "test",
87
+ level: "warn",
88
+ output: (line) => lines.push(line),
89
+ });
90
+ warnLogger.debug("no");
91
+ warnLogger.info("no");
92
+ warnLogger.warn("yes");
93
+ warnLogger.error("yes");
94
+ expect(lines).toHaveLength(2);
95
+ });
96
+
97
+ it("only outputs error when level is error", () => {
98
+ const errorLogger = new Logger({
99
+ component: "test",
100
+ level: "error",
101
+ output: (line) => lines.push(line),
102
+ });
103
+ errorLogger.debug("no");
104
+ errorLogger.info("no");
105
+ errorLogger.warn("no");
106
+ errorLogger.error("yes");
107
+ expect(lines).toHaveLength(1);
108
+ });
109
+ });
110
+
111
+ describe("child logger", () => {
112
+ it("creates child with different component", () => {
113
+ const child = logger.child({ component: "sub-system" });
114
+ child.info("child message");
115
+ const parsed = JSON.parse(lines[0]);
116
+ expect(parsed.component).toBe("sub-system");
117
+ });
118
+
119
+ it("child inherits parent output", () => {
120
+ const child = logger.child({ component: "child" });
121
+ child.warn("from child");
122
+ expect(lines).toHaveLength(1);
123
+ expect(JSON.parse(lines[0]).component).toBe("child");
124
+ });
125
+ });
126
+
127
+ describe("default output", () => {
128
+ it("writes to stderr by default", () => {
129
+ const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
130
+ const defaultLogger = new Logger({ component: "default-test", level: "info" });
131
+ defaultLogger.info("stderr test");
132
+ expect(stderrWrite).toHaveBeenCalled();
133
+ const written = stderrWrite.mock.calls[0][0] as string;
134
+ expect(written).toContain("stderr test");
135
+ expect(written.endsWith("\n")).toBe(true);
136
+ stderrWrite.mockRestore();
137
+ });
138
+ });
139
+ });
@@ -0,0 +1,72 @@
1
+ export type LogLevel = "debug" | "info" | "warn" | "error";
2
+
3
+ const LEVEL_PRIORITY: Record<LogLevel, number> = {
4
+ debug: 0,
5
+ info: 1,
6
+ warn: 2,
7
+ error: 3,
8
+ };
9
+
10
+ export interface LogEntry {
11
+ timestamp: string;
12
+ level: LogLevel;
13
+ component: string;
14
+ message: string;
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ export interface LoggerOptions {
19
+ component: string;
20
+ level?: LogLevel;
21
+ output?: (line: string) => void;
22
+ }
23
+
24
+ export class Logger {
25
+ private component: string;
26
+ private minLevel: number;
27
+ private output: (line: string) => void;
28
+
29
+ constructor(options: LoggerOptions) {
30
+ this.component = options.component;
31
+ this.minLevel = LEVEL_PRIORITY[options.level ?? "info"];
32
+ this.output = options.output ?? ((line: string) => process.stderr.write(line + "\n"));
33
+ }
34
+
35
+ debug(message: string, context?: Record<string, unknown>): void {
36
+ this.log("debug", message, context);
37
+ }
38
+
39
+ info(message: string, context?: Record<string, unknown>): void {
40
+ this.log("info", message, context);
41
+ }
42
+
43
+ warn(message: string, context?: Record<string, unknown>): void {
44
+ this.log("warn", message, context);
45
+ }
46
+
47
+ error(message: string, context?: Record<string, unknown>): void {
48
+ this.log("error", message, context);
49
+ }
50
+
51
+ private log(level: LogLevel, message: string, context?: Record<string, unknown>): void {
52
+ if (LEVEL_PRIORITY[level] < this.minLevel) return;
53
+
54
+ const entry: LogEntry = {
55
+ timestamp: new Date().toISOString(),
56
+ level,
57
+ component: this.component,
58
+ message,
59
+ ...context,
60
+ };
61
+
62
+ this.output(JSON.stringify(entry));
63
+ }
64
+
65
+ child(overrides: Partial<LoggerOptions>): Logger {
66
+ return new Logger({
67
+ component: overrides.component ?? this.component,
68
+ level: overrides.level ?? (Object.keys(LEVEL_PRIORITY) as LogLevel[])[this.minLevel],
69
+ output: overrides.output ?? this.output,
70
+ });
71
+ }
72
+ }
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { metrics } from "@opentelemetry/api";
3
+ import { GatewayMetrics } from "./metrics.js";
4
+ import type { TelemetryConfig } from "../types.js";
5
+
6
+ const testConfig: TelemetryConfig = {
7
+ enabled: true,
8
+ serviceName: "test-gateway",
9
+ sampleRate: 1.0,
10
+ };
11
+
12
+ describe("GatewayMetrics", () => {
13
+ let gatewayMetrics: GatewayMetrics;
14
+
15
+ beforeEach(() => {
16
+ gatewayMetrics = new GatewayMetrics(testConfig);
17
+ });
18
+
19
+ describe("construction", () => {
20
+ it("creates all metric instruments", () => {
21
+ expect(gatewayMetrics.toolCallsTotal).toBeDefined();
22
+ expect(gatewayMetrics.toolCallDurationMs).toBeDefined();
23
+ expect(gatewayMetrics.upstreamStatus).toBeDefined();
24
+ expect(gatewayMetrics.policyDenialsTotal).toBeDefined();
25
+ });
26
+ });
27
+
28
+ describe("recordToolCall", () => {
29
+ it("increments tool_calls_total counter without throwing", () => {
30
+ expect(() =>
31
+ gatewayMetrics.recordToolCall({
32
+ namespace: "test",
33
+ tool: "test/my_tool",
34
+ principal: "agent:user1",
35
+ success: "true",
36
+ }),
37
+ ).not.toThrow();
38
+ });
39
+
40
+ it("accepts different label combinations", () => {
41
+ expect(() =>
42
+ gatewayMetrics.recordToolCall({
43
+ namespace: "prod",
44
+ tool: "prod/fetch_data",
45
+ principal: "anonymous",
46
+ success: "false",
47
+ }),
48
+ ).not.toThrow();
49
+ });
50
+ });
51
+
52
+ describe("recordDuration", () => {
53
+ it("records duration histogram without throwing", () => {
54
+ expect(() =>
55
+ gatewayMetrics.recordDuration(42.5, {
56
+ namespace: "test",
57
+ upstream: "my-server",
58
+ }),
59
+ ).not.toThrow();
60
+ });
61
+
62
+ it("handles zero duration", () => {
63
+ expect(() =>
64
+ gatewayMetrics.recordDuration(0, {
65
+ namespace: "test",
66
+ upstream: "fast-server",
67
+ }),
68
+ ).not.toThrow();
69
+ });
70
+
71
+ it("handles large durations", () => {
72
+ expect(() =>
73
+ gatewayMetrics.recordDuration(30000, {
74
+ namespace: "test",
75
+ upstream: "slow-server",
76
+ }),
77
+ ).not.toThrow();
78
+ });
79
+ });
80
+
81
+ describe("setUpstreamStatus", () => {
82
+ it("records gauge value for healthy status", () => {
83
+ expect(() =>
84
+ gatewayMetrics.setUpstreamStatus("my-upstream", "healthy", 1),
85
+ ).not.toThrow();
86
+ });
87
+
88
+ it("records gauge value for unhealthy status", () => {
89
+ expect(() =>
90
+ gatewayMetrics.setUpstreamStatus("my-upstream", "unavailable", 0),
91
+ ).not.toThrow();
92
+ });
93
+ });
94
+
95
+ describe("recordPolicyDenial", () => {
96
+ it("increments policy_denials_total counter without throwing", () => {
97
+ expect(() =>
98
+ gatewayMetrics.recordPolicyDenial({
99
+ principal: "agent:blocked",
100
+ tool: "test/dangerous_tool",
101
+ reason: "unauthorized",
102
+ }),
103
+ ).not.toThrow();
104
+ });
105
+
106
+ it("records denial with rate limit reason", () => {
107
+ expect(() =>
108
+ gatewayMetrics.recordPolicyDenial({
109
+ principal: "agent:heavy",
110
+ tool: "test/expensive_tool",
111
+ reason: "rate_limit_exceeded",
112
+ }),
113
+ ).not.toThrow();
114
+ });
115
+ });
116
+
117
+ describe("with custom MeterProvider", () => {
118
+ it("uses the global meter provider", () => {
119
+ const getMeterSpy = vi.spyOn(metrics, "getMeter");
120
+ new GatewayMetrics(testConfig);
121
+ expect(getMeterSpy).toHaveBeenCalledWith("mcp-audit-gateway", "0.1.0");
122
+ getMeterSpy.mockRestore();
123
+ });
124
+ });
125
+ });