@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.
- package/.github/workflows/npm-publish.yml +21 -0
- package/CONTRIBUTING.md +41 -0
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/attestation/audit-log.d.ts +25 -0
- package/dist/attestation/audit-log.d.ts.map +1 -0
- package/dist/attestation/audit-log.js +95 -0
- package/dist/attestation/audit-log.js.map +1 -0
- package/dist/attestation/bugfix.test.d.ts +2 -0
- package/dist/attestation/bugfix.test.d.ts.map +1 -0
- package/dist/attestation/bugfix.test.js +103 -0
- package/dist/attestation/bugfix.test.js.map +1 -0
- package/dist/attestation/chain.test.d.ts +2 -0
- package/dist/attestation/chain.test.d.ts.map +1 -0
- package/dist/attestation/chain.test.js +153 -0
- package/dist/attestation/chain.test.js.map +1 -0
- package/dist/attestation/keygen.d.ts +5 -0
- package/dist/attestation/keygen.d.ts.map +1 -0
- package/dist/attestation/keygen.js +12 -0
- package/dist/attestation/keygen.js.map +1 -0
- package/dist/attestation/signer.d.ts +25 -0
- package/dist/attestation/signer.d.ts.map +1 -0
- package/dist/attestation/signer.js +93 -0
- package/dist/attestation/signer.js.map +1 -0
- package/dist/attestation/signer.test.d.ts +2 -0
- package/dist/attestation/signer.test.d.ts.map +1 -0
- package/dist/attestation/signer.test.js +62 -0
- package/dist/attestation/signer.test.js.map +1 -0
- package/dist/attestation/verify.d.ts +26 -0
- package/dist/attestation/verify.d.ts.map +1 -0
- package/dist/attestation/verify.js +104 -0
- package/dist/attestation/verify.js.map +1 -0
- package/dist/attestation/verify.test.d.ts +2 -0
- package/dist/attestation/verify.test.d.ts.map +1 -0
- package/dist/attestation/verify.test.js +79 -0
- package/dist/attestation/verify.test.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +364 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/integration.test.d.ts +2 -0
- package/dist/integration.test.d.ts.map +1 -0
- package/dist/integration.test.js +88 -0
- package/dist/integration.test.js.map +1 -0
- package/dist/policy/engine.d.ts +24 -0
- package/dist/policy/engine.d.ts.map +1 -0
- package/dist/policy/engine.js +130 -0
- package/dist/policy/engine.js.map +1 -0
- package/dist/policy/engine.test.d.ts +2 -0
- package/dist/policy/engine.test.d.ts.map +1 -0
- package/dist/policy/engine.test.js +80 -0
- package/dist/policy/engine.test.js.map +1 -0
- package/dist/proxy/gateway.d.ts +54 -0
- package/dist/proxy/gateway.d.ts.map +1 -0
- package/dist/proxy/gateway.js +245 -0
- package/dist/proxy/gateway.js.map +1 -0
- package/dist/proxy/gateway.test.d.ts +2 -0
- package/dist/proxy/gateway.test.d.ts.map +1 -0
- package/dist/proxy/gateway.test.js +93 -0
- package/dist/proxy/gateway.test.js.map +1 -0
- package/dist/proxy/mcp-server-adapter.d.ts +21 -0
- package/dist/proxy/mcp-server-adapter.d.ts.map +1 -0
- package/dist/proxy/mcp-server-adapter.js +98 -0
- package/dist/proxy/mcp-server-adapter.js.map +1 -0
- package/dist/proxy/mcp-server-adapter.test.d.ts +2 -0
- package/dist/proxy/mcp-server-adapter.test.d.ts.map +1 -0
- package/dist/proxy/mcp-server-adapter.test.js +160 -0
- package/dist/proxy/mcp-server-adapter.test.js.map +1 -0
- package/dist/proxy/upstream-manager.d.ts +49 -0
- package/dist/proxy/upstream-manager.d.ts.map +1 -0
- package/dist/proxy/upstream-manager.js +246 -0
- package/dist/proxy/upstream-manager.js.map +1 -0
- package/dist/proxy/upstream-manager.test.d.ts +2 -0
- package/dist/proxy/upstream-manager.test.d.ts.map +1 -0
- package/dist/proxy/upstream-manager.test.js +413 -0
- package/dist/proxy/upstream-manager.test.js.map +1 -0
- package/dist/telemetry/logger.d.ts +26 -0
- package/dist/telemetry/logger.d.ts.map +1 -0
- package/dist/telemetry/logger.js +48 -0
- package/dist/telemetry/logger.js.map +1 -0
- package/dist/telemetry/logger.test.d.ts +2 -0
- package/dist/telemetry/logger.test.d.ts.map +1 -0
- package/dist/telemetry/logger.test.js +124 -0
- package/dist/telemetry/logger.test.js.map +1 -0
- package/dist/telemetry/metrics.d.ts +41 -0
- package/dist/telemetry/metrics.d.ts.map +1 -0
- package/dist/telemetry/metrics.js +64 -0
- package/dist/telemetry/metrics.js.map +1 -0
- package/dist/telemetry/metrics.test.d.ts +2 -0
- package/dist/telemetry/metrics.test.d.ts.map +1 -0
- package/dist/telemetry/metrics.test.js +93 -0
- package/dist/telemetry/metrics.test.js.map +1 -0
- package/dist/telemetry/tracer.d.ts +16 -0
- package/dist/telemetry/tracer.d.ts.map +1 -0
- package/dist/telemetry/tracer.js +71 -0
- package/dist/telemetry/tracer.js.map +1 -0
- package/dist/types.d.ts +477 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +68 -0
- package/dist/types.js.map +1 -0
- package/dist/wrap/proxy.d.ts +2 -0
- package/dist/wrap/proxy.d.ts.map +1 -0
- package/dist/wrap/proxy.js +152 -0
- package/dist/wrap/proxy.js.map +1 -0
- package/dist/wrap/proxy.test.d.ts +2 -0
- package/dist/wrap/proxy.test.d.ts.map +1 -0
- package/dist/wrap/proxy.test.js +155 -0
- package/dist/wrap/proxy.test.js.map +1 -0
- package/examples/README.md +33 -0
- package/examples/claude-desktop.json +50 -0
- package/examples/multi-agent-production.json +72 -0
- package/gateway.config.example.json +61 -0
- package/package.json +49 -0
- package/src/attestation/audit-log.ts +126 -0
- package/src/attestation/bugfix.test.ts +113 -0
- package/src/attestation/chain.test.ts +188 -0
- package/src/attestation/keygen.ts +18 -0
- package/src/attestation/signer.test.ts +75 -0
- package/src/attestation/signer.ts +105 -0
- package/src/attestation/verify.test.ts +86 -0
- package/src/attestation/verify.ts +131 -0
- package/src/cli.ts +407 -0
- package/src/index.ts +21 -0
- package/src/integration.test.ts +108 -0
- package/src/policy/engine.test.ts +95 -0
- package/src/policy/engine.ts +162 -0
- package/src/proxy/gateway.test.ts +108 -0
- package/src/proxy/gateway.ts +328 -0
- package/src/proxy/mcp-server-adapter.test.ts +197 -0
- package/src/proxy/mcp-server-adapter.ts +140 -0
- package/src/proxy/upstream-manager.test.ts +534 -0
- package/src/proxy/upstream-manager.ts +326 -0
- package/src/telemetry/logger.test.ts +139 -0
- package/src/telemetry/logger.ts +72 -0
- package/src/telemetry/metrics.test.ts +125 -0
- package/src/telemetry/metrics.ts +83 -0
- package/src/telemetry/tracer.ts +90 -0
- package/src/types.ts +113 -0
- package/src/wrap/proxy.test.ts +175 -0
- package/src/wrap/proxy.ts +192 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { metrics, ValueType } from "@opentelemetry/api";
|
|
2
|
+
import type { Counter, Histogram, Gauge, Meter } from "@opentelemetry/api";
|
|
3
|
+
import type { TelemetryConfig } from "../types.js";
|
|
4
|
+
|
|
5
|
+
const METER_NAME = "mcp-audit-gateway";
|
|
6
|
+
|
|
7
|
+
export interface MetricLabels {
|
|
8
|
+
toolCall: { namespace: string; tool: string; principal: string; success: string };
|
|
9
|
+
duration: { namespace: string; upstream: string };
|
|
10
|
+
upstreamStatus: { upstream: string; status: string };
|
|
11
|
+
policyDenial: { principal: string; tool: string; reason: string };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class GatewayMetrics {
|
|
15
|
+
private meter: Meter;
|
|
16
|
+
private _toolCallsTotal: Counter;
|
|
17
|
+
private _toolCallDurationMs: Histogram;
|
|
18
|
+
private _upstreamStatus: Gauge;
|
|
19
|
+
private _policyDenialsTotal: Counter;
|
|
20
|
+
private _enabled: boolean;
|
|
21
|
+
|
|
22
|
+
constructor(config: TelemetryConfig) {
|
|
23
|
+
this._enabled = config.enabled;
|
|
24
|
+
this.meter = metrics.getMeter(METER_NAME, "0.1.0");
|
|
25
|
+
|
|
26
|
+
this._toolCallsTotal = this.meter.createCounter("mcp.gateway.tool_calls_total", {
|
|
27
|
+
description: "Total number of tool calls processed by the gateway",
|
|
28
|
+
valueType: ValueType.INT,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
this._toolCallDurationMs = this.meter.createHistogram("mcp.gateway.tool_call_duration_ms", {
|
|
32
|
+
description: "Duration of tool call execution in milliseconds",
|
|
33
|
+
unit: "ms",
|
|
34
|
+
valueType: ValueType.DOUBLE,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
this._upstreamStatus = this.meter.createGauge("mcp.gateway.upstream_status", {
|
|
38
|
+
description: "Current status of upstream servers (1=healthy, 0=unhealthy)",
|
|
39
|
+
valueType: ValueType.INT,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
this._policyDenialsTotal = this.meter.createCounter("mcp.gateway.policy_denials_total", {
|
|
43
|
+
description: "Total number of policy denials",
|
|
44
|
+
valueType: ValueType.INT,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
recordToolCall(labels: MetricLabels["toolCall"]): void {
|
|
49
|
+
if (!this._enabled) return;
|
|
50
|
+
this._toolCallsTotal.add(1, labels);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
recordDuration(durationMs: number, labels: MetricLabels["duration"]): void {
|
|
54
|
+
if (!this._enabled) return;
|
|
55
|
+
this._toolCallDurationMs.record(durationMs, labels);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setUpstreamStatus(upstream: string, status: string, value: number): void {
|
|
59
|
+
if (!this._enabled) return;
|
|
60
|
+
this._upstreamStatus.record(value, { upstream, status });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
recordPolicyDenial(labels: MetricLabels["policyDenial"]): void {
|
|
64
|
+
if (!this._enabled) return;
|
|
65
|
+
this._policyDenialsTotal.add(1, labels);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get toolCallsTotal(): Counter {
|
|
69
|
+
return this._toolCallsTotal;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get toolCallDurationMs(): Histogram {
|
|
73
|
+
return this._toolCallDurationMs;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get upstreamStatus(): Gauge {
|
|
77
|
+
return this._upstreamStatus;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get policyDenialsTotal(): Counter {
|
|
81
|
+
return this._policyDenialsTotal;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { trace, context, SpanKind, SpanStatusCode, propagation, INVALID_SPAN_CONTEXT } from "@opentelemetry/api";
|
|
2
|
+
import type { Span, Tracer, SpanContext } from "@opentelemetry/api";
|
|
3
|
+
import type { TelemetryConfig } from "../types.js";
|
|
4
|
+
|
|
5
|
+
const NOOP_SPAN: Span = {
|
|
6
|
+
spanContext(): SpanContext { return INVALID_SPAN_CONTEXT; },
|
|
7
|
+
setAttribute() { return this; },
|
|
8
|
+
setAttributes() { return this; },
|
|
9
|
+
addEvent() { return this; },
|
|
10
|
+
addLink() { return this; },
|
|
11
|
+
addLinks() { return this; },
|
|
12
|
+
setStatus() { return this; },
|
|
13
|
+
updateName() { return this; },
|
|
14
|
+
end() {},
|
|
15
|
+
isRecording() { return false; },
|
|
16
|
+
recordException() {},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const TRACER_NAME = "mcp-audit-gateway";
|
|
20
|
+
|
|
21
|
+
export class GatewayTracer {
|
|
22
|
+
private tracer: Tracer;
|
|
23
|
+
|
|
24
|
+
constructor(private config: TelemetryConfig) {
|
|
25
|
+
this.tracer = trace.getTracer(TRACER_NAME, "0.1.0");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
startRouteSpan(
|
|
29
|
+
method: string,
|
|
30
|
+
toolName: string | undefined,
|
|
31
|
+
upstream: string,
|
|
32
|
+
traceContext?: { traceparent?: string; tracestate?: string },
|
|
33
|
+
): Span {
|
|
34
|
+
if (!this.config.enabled) return NOOP_SPAN;
|
|
35
|
+
|
|
36
|
+
const spanName = toolName
|
|
37
|
+
? `mcp.gateway/route ${upstream}`
|
|
38
|
+
: `mcp.${method}`;
|
|
39
|
+
|
|
40
|
+
const parentContext = traceContext?.traceparent
|
|
41
|
+
? this.extractContext(traceContext)
|
|
42
|
+
: context.active();
|
|
43
|
+
|
|
44
|
+
const span = this.tracer.startSpan(
|
|
45
|
+
spanName,
|
|
46
|
+
{
|
|
47
|
+
kind: SpanKind.INTERNAL,
|
|
48
|
+
attributes: {
|
|
49
|
+
"mcp.method": method,
|
|
50
|
+
"mcp.gateway.name": this.config.serviceName,
|
|
51
|
+
"mcp.gateway.route.target": upstream,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
parentContext,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (toolName) {
|
|
58
|
+
span.setAttribute("mcp.tool.name", toolName);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return span;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
endSpan(span: Span, success: boolean, errorCode?: number): void {
|
|
65
|
+
if (!success) {
|
|
66
|
+
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
67
|
+
if (errorCode != null) {
|
|
68
|
+
span.setAttribute("mcp.error.code", errorCode);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
span.end();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
recordDuration(span: Span, durationMs: number): void {
|
|
75
|
+
span.setAttribute("mcp.duration_ms", durationMs);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getTraceParent(span: Span): string {
|
|
79
|
+
const spanContext = span.spanContext();
|
|
80
|
+
const flags = spanContext.traceFlags.toString(16).padStart(2, "0");
|
|
81
|
+
return `00-${spanContext.traceId}-${spanContext.spanId}-${flags}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private extractContext(traceContext: { traceparent?: string; tracestate?: string }) {
|
|
85
|
+
const carrier: Record<string, string> = {};
|
|
86
|
+
if (traceContext.traceparent) carrier["traceparent"] = traceContext.traceparent;
|
|
87
|
+
if (traceContext.tracestate) carrier["tracestate"] = traceContext.tracestate;
|
|
88
|
+
return propagation.extract(context.active(), carrier);
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const UpstreamConfigSchema = z.object({
|
|
4
|
+
name: z.string(),
|
|
5
|
+
namespace: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/),
|
|
6
|
+
transport: z.discriminatedUnion("type", [
|
|
7
|
+
z.object({
|
|
8
|
+
type: z.literal("stdio"),
|
|
9
|
+
command: z.string(),
|
|
10
|
+
args: z.array(z.string()).optional(),
|
|
11
|
+
env: z.record(z.string()).optional(),
|
|
12
|
+
}),
|
|
13
|
+
z.object({
|
|
14
|
+
type: z.literal("streamable-http"),
|
|
15
|
+
url: z.string().url(),
|
|
16
|
+
headers: z.record(z.string()).optional(),
|
|
17
|
+
}),
|
|
18
|
+
]),
|
|
19
|
+
healthCheckIntervalMs: z.number().positive().optional(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const PolicyRuleSchema = z.object({
|
|
23
|
+
effect: z.enum(["allow", "deny"]),
|
|
24
|
+
principals: z.array(z.string()).optional(),
|
|
25
|
+
tools: z.array(z.string()).optional(),
|
|
26
|
+
namespaces: z.array(z.string()).optional(),
|
|
27
|
+
rateLimit: z
|
|
28
|
+
.object({
|
|
29
|
+
maxPerMinute: z.number().positive().optional(),
|
|
30
|
+
maxPerHour: z.number().positive().optional(),
|
|
31
|
+
})
|
|
32
|
+
.optional(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const AttestationConfigSchema = z.object({
|
|
36
|
+
enabled: z.boolean().default(true),
|
|
37
|
+
algorithm: z.enum(["ed25519", "hmac-sha256"]).default("ed25519"),
|
|
38
|
+
keyPath: z.string().optional(),
|
|
39
|
+
secret: z.string().optional(),
|
|
40
|
+
includeParams: z.boolean().optional().default(false),
|
|
41
|
+
includeResult: z.boolean().optional().default(false),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const TelemetryConfigSchema = z.object({
|
|
45
|
+
enabled: z.boolean().default(true),
|
|
46
|
+
exporterEndpoint: z.string().url().optional(),
|
|
47
|
+
serviceName: z.string().default("mcp-audit-gateway"),
|
|
48
|
+
sampleRate: z.number().min(0).max(1).default(1.0),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const GatewayConfigSchema = z.object({
|
|
52
|
+
name: z.string().default("mcp-audit-gateway"),
|
|
53
|
+
version: z.string().default("0.1.0"),
|
|
54
|
+
listen: z.object({
|
|
55
|
+
transport: z.enum(["stdio", "streamable-http"]).default("streamable-http"),
|
|
56
|
+
port: z.number().positive().default(3100),
|
|
57
|
+
host: z.string().default("127.0.0.1"),
|
|
58
|
+
principalHeader: z.string().optional(),
|
|
59
|
+
}).default({}),
|
|
60
|
+
upstreams: z.array(UpstreamConfigSchema).min(1),
|
|
61
|
+
policy: z.object({
|
|
62
|
+
defaultEffect: z.enum(["allow", "deny"]).default("allow"),
|
|
63
|
+
rules: z.array(PolicyRuleSchema).default([]),
|
|
64
|
+
}).default({}),
|
|
65
|
+
attestation: AttestationConfigSchema.default({}),
|
|
66
|
+
telemetry: TelemetryConfigSchema.default({}),
|
|
67
|
+
auditLog: z.object({
|
|
68
|
+
enabled: z.boolean().default(true),
|
|
69
|
+
path: z.string().default("./audit.jsonl"),
|
|
70
|
+
rotateAfterMb: z.number().positive().default(100),
|
|
71
|
+
}).default({}),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export type UpstreamConfig = z.infer<typeof UpstreamConfigSchema>;
|
|
75
|
+
export type PolicyRule = z.infer<typeof PolicyRuleSchema>;
|
|
76
|
+
export type AttestationConfig = z.infer<typeof AttestationConfigSchema>;
|
|
77
|
+
export type TelemetryConfig = z.infer<typeof TelemetryConfigSchema>;
|
|
78
|
+
export type GatewayConfig = z.infer<typeof GatewayConfigSchema>;
|
|
79
|
+
|
|
80
|
+
export interface UpstreamStatus {
|
|
81
|
+
name: string;
|
|
82
|
+
namespace: string;
|
|
83
|
+
status: "healthy" | "degraded" | "unavailable" | "cooldown";
|
|
84
|
+
toolCount: number;
|
|
85
|
+
lastSuccessfulContact: string | null;
|
|
86
|
+
degradedReason?: string;
|
|
87
|
+
unavailableReason?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface AuditRecord {
|
|
91
|
+
id: string;
|
|
92
|
+
timestamp: string;
|
|
93
|
+
method: string;
|
|
94
|
+
toolName?: string;
|
|
95
|
+
namespace?: string;
|
|
96
|
+
upstream?: string;
|
|
97
|
+
principal?: string;
|
|
98
|
+
durationMs: number;
|
|
99
|
+
success: boolean;
|
|
100
|
+
errorCode?: number;
|
|
101
|
+
previousHash?: string;
|
|
102
|
+
attestation?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ToolEntry {
|
|
106
|
+
name: string;
|
|
107
|
+
originalName: string;
|
|
108
|
+
namespace: string;
|
|
109
|
+
upstream: string;
|
|
110
|
+
description?: string;
|
|
111
|
+
inputSchema?: unknown;
|
|
112
|
+
annotations?: Record<string, unknown>;
|
|
113
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { readFile, rm, mkdir } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
|
|
7
|
+
const AUDIT_DIR = join(homedir(), ".mcp-audit");
|
|
8
|
+
const LOG_PATH = join(AUDIT_DIR, "audit.jsonl");
|
|
9
|
+
const CLI_PATH = join(import.meta.dirname, "../../dist/cli.js");
|
|
10
|
+
|
|
11
|
+
function sendJsonRpc(proc: ReturnType<typeof spawn>, msg: object) {
|
|
12
|
+
proc.stdin!.write(JSON.stringify(msg) + "\n");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function collectLines(proc: ReturnType<typeof spawn>): Promise<string[]> {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const lines: string[] = [];
|
|
18
|
+
proc.stdout!.on("data", (chunk) => {
|
|
19
|
+
const parts = chunk.toString().split("\n").filter((l: string) => l.trim());
|
|
20
|
+
lines.push(...parts);
|
|
21
|
+
});
|
|
22
|
+
proc.on("close", () => resolve(lines));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("wrap proxy", () => {
|
|
27
|
+
let originalLog: string | null = null;
|
|
28
|
+
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
await mkdir(AUDIT_DIR, { recursive: true });
|
|
31
|
+
try {
|
|
32
|
+
originalLog = await readFile(LOG_PATH, "utf-8");
|
|
33
|
+
} catch {
|
|
34
|
+
originalLog = null;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(async () => {
|
|
39
|
+
if (originalLog !== null) {
|
|
40
|
+
const { writeFile } = await import("node:fs/promises");
|
|
41
|
+
await writeFile(LOG_PATH, originalLog);
|
|
42
|
+
} else {
|
|
43
|
+
try { await rm(LOG_PATH); } catch {}
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("forwards non-tool messages transparently", async () => {
|
|
48
|
+
const proc = spawn("node", [CLI_PATH, "wrap", "--", "node", "-e", `
|
|
49
|
+
process.stdin.setEncoding('utf-8');
|
|
50
|
+
let buf = '';
|
|
51
|
+
process.stdin.on('data', d => {
|
|
52
|
+
buf += d;
|
|
53
|
+
const lines = buf.split('\\n');
|
|
54
|
+
buf = lines.pop();
|
|
55
|
+
for (const line of lines) {
|
|
56
|
+
if (!line.trim()) continue;
|
|
57
|
+
const req = JSON.parse(line);
|
|
58
|
+
if (req.method === 'initialize') {
|
|
59
|
+
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:req.id,result:{capabilities:{tools:{}}}}) + '\\n');
|
|
60
|
+
} else if (req.method === 'resources/list') {
|
|
61
|
+
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:req.id,result:{resources:[]}}) + '\\n');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
`], { stdio: ["pipe", "pipe", "pipe"] });
|
|
66
|
+
|
|
67
|
+
const lines = collectLines(proc);
|
|
68
|
+
|
|
69
|
+
sendJsonRpc(proc, { jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
|
|
70
|
+
sendJsonRpc(proc, { jsonrpc: "2.0", id: 2, method: "resources/list", params: {} });
|
|
71
|
+
|
|
72
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
73
|
+
proc.stdin!.end();
|
|
74
|
+
|
|
75
|
+
const output = await lines;
|
|
76
|
+
const parsed = output.map((l) => JSON.parse(l));
|
|
77
|
+
|
|
78
|
+
expect(parsed).toHaveLength(2);
|
|
79
|
+
expect(parsed[0].id).toBe(1);
|
|
80
|
+
expect(parsed[0].result.capabilities).toBeDefined();
|
|
81
|
+
expect(parsed[1].id).toBe(2);
|
|
82
|
+
expect(parsed[1].result.resources).toBeDefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("logs tool calls with attestation", async () => {
|
|
86
|
+
const proc = spawn("node", [CLI_PATH, "wrap", "--", "node", "-e", `
|
|
87
|
+
process.stdin.setEncoding('utf-8');
|
|
88
|
+
let buf = '';
|
|
89
|
+
process.stdin.on('data', d => {
|
|
90
|
+
buf += d;
|
|
91
|
+
const lines = buf.split('\\n');
|
|
92
|
+
buf = lines.pop();
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
if (!line.trim()) continue;
|
|
95
|
+
const req = JSON.parse(line);
|
|
96
|
+
if (req.method === 'tools/call') {
|
|
97
|
+
process.stdout.write(JSON.stringify({
|
|
98
|
+
jsonrpc:'2.0', id:req.id,
|
|
99
|
+
result:{content:[{type:'text',text:'done'}]}
|
|
100
|
+
}) + '\\n');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
`], { stdio: ["pipe", "pipe", "pipe"] });
|
|
105
|
+
|
|
106
|
+
const lines = collectLines(proc);
|
|
107
|
+
|
|
108
|
+
sendJsonRpc(proc, {
|
|
109
|
+
jsonrpc: "2.0", id: 1, method: "tools/call",
|
|
110
|
+
params: { name: "fs/read_file", arguments: { path: "/tmp/test" } },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
114
|
+
proc.stdin!.end();
|
|
115
|
+
|
|
116
|
+
const output = await lines;
|
|
117
|
+
expect(output.length).toBeGreaterThanOrEqual(1);
|
|
118
|
+
|
|
119
|
+
const response = JSON.parse(output[0]);
|
|
120
|
+
expect(response.result.content[0].text).toBe("done");
|
|
121
|
+
|
|
122
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
123
|
+
const logContent = await readFile(LOG_PATH, "utf-8");
|
|
124
|
+
const logLines = logContent.trimEnd().split("\n");
|
|
125
|
+
const lastEntry = JSON.parse(logLines[logLines.length - 1]);
|
|
126
|
+
|
|
127
|
+
expect(lastEntry.toolName).toBe("fs/read_file");
|
|
128
|
+
expect(lastEntry.success).toBe(true);
|
|
129
|
+
expect(lastEntry.attestation).toBeDefined();
|
|
130
|
+
expect(lastEntry.attestation.length).toBe(64);
|
|
131
|
+
expect(lastEntry.previousHash).toBeDefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("maintains hash chain across calls", async () => {
|
|
135
|
+
const proc = spawn("node", [CLI_PATH, "wrap", "--", "node", "-e", `
|
|
136
|
+
process.stdin.setEncoding('utf-8');
|
|
137
|
+
let buf = '';
|
|
138
|
+
process.stdin.on('data', d => {
|
|
139
|
+
buf += d;
|
|
140
|
+
const lines = buf.split('\\n');
|
|
141
|
+
buf = lines.pop();
|
|
142
|
+
for (const line of lines) {
|
|
143
|
+
if (!line.trim()) continue;
|
|
144
|
+
const req = JSON.parse(line);
|
|
145
|
+
if (req.method === 'tools/call') {
|
|
146
|
+
process.stdout.write(JSON.stringify({
|
|
147
|
+
jsonrpc:'2.0', id:req.id,
|
|
148
|
+
result:{content:[{type:'text',text:'ok'}]}
|
|
149
|
+
}) + '\\n');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
`], { stdio: ["pipe", "pipe", "pipe"] });
|
|
154
|
+
|
|
155
|
+
collectLines(proc);
|
|
156
|
+
|
|
157
|
+
sendJsonRpc(proc, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "tool_a", arguments: {} } });
|
|
158
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
159
|
+
sendJsonRpc(proc, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "tool_b", arguments: {} } });
|
|
160
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
161
|
+
sendJsonRpc(proc, { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "tool_c", arguments: {} } });
|
|
162
|
+
|
|
163
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
164
|
+
proc.stdin!.end();
|
|
165
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
166
|
+
|
|
167
|
+
const logContent = await readFile(LOG_PATH, "utf-8");
|
|
168
|
+
const logLines = logContent.trimEnd().split("\n");
|
|
169
|
+
const entries = logLines.slice(-3).map((l) => JSON.parse(l));
|
|
170
|
+
|
|
171
|
+
expect(entries[0].previousHash).toBeDefined();
|
|
172
|
+
expect(entries[1].previousHash).not.toBe(entries[0].previousHash);
|
|
173
|
+
expect(entries[2].previousHash).not.toBe(entries[1].previousHash);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { appendFile, mkdir, readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { createHmac, randomBytes, createHash, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
interface AuditEntry {
|
|
9
|
+
id: string;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
method: string;
|
|
12
|
+
toolName: string;
|
|
13
|
+
namespace: null;
|
|
14
|
+
upstream: null;
|
|
15
|
+
principal: null;
|
|
16
|
+
args: Record<string, unknown>;
|
|
17
|
+
durationMs: number;
|
|
18
|
+
success: boolean;
|
|
19
|
+
errorCode: null;
|
|
20
|
+
previousHash: string;
|
|
21
|
+
attestation: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PendingCall {
|
|
25
|
+
toolName: string;
|
|
26
|
+
args: Record<string, unknown>;
|
|
27
|
+
startTime: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runWrapProxy(command: string, args: string[]): Promise<void> {
|
|
31
|
+
const auditDir = join(homedir(), ".mcp-audit");
|
|
32
|
+
await mkdir(auditDir, { recursive: true });
|
|
33
|
+
|
|
34
|
+
const keyPath = join(auditDir, "key.hex");
|
|
35
|
+
const logPath = join(auditDir, "audit.jsonl");
|
|
36
|
+
const secret = await ensureKey(keyPath);
|
|
37
|
+
|
|
38
|
+
let lastHash = await restoreLastHash(logPath);
|
|
39
|
+
let writeQueue: Promise<void> = Promise.resolve();
|
|
40
|
+
const pending = new Map<string | number, PendingCall>();
|
|
41
|
+
|
|
42
|
+
const child = spawn(command, args, {
|
|
43
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
44
|
+
env: process.env,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
child.on("error", (err) => {
|
|
48
|
+
process.stderr.write(`[mcp-audit] failed to spawn: ${err.message}\n`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
child.on("exit", (code) => {
|
|
53
|
+
process.exit(code ?? 0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const clientRl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
57
|
+
const serverRl = createInterface({ input: child.stdout!, crlfDelay: Infinity });
|
|
58
|
+
|
|
59
|
+
clientRl.on("line", (line) => {
|
|
60
|
+
if (!line.trim()) return;
|
|
61
|
+
try {
|
|
62
|
+
const msg = JSON.parse(line);
|
|
63
|
+
if (msg.method === "tools/call" && msg.id !== undefined) {
|
|
64
|
+
pending.set(msg.id, {
|
|
65
|
+
toolName: msg.params?.name ?? "unknown",
|
|
66
|
+
args: msg.params?.arguments ?? {},
|
|
67
|
+
startTime: Date.now(),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
child.stdin!.write(line + "\n");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
serverRl.on("line", (line) => {
|
|
75
|
+
if (!line.trim()) return;
|
|
76
|
+
|
|
77
|
+
process.stdout.write(line + "\n");
|
|
78
|
+
|
|
79
|
+
let msg: any;
|
|
80
|
+
try {
|
|
81
|
+
msg = JSON.parse(line);
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (msg.id !== undefined && pending.has(msg.id)) {
|
|
87
|
+
const call = pending.get(msg.id)!;
|
|
88
|
+
pending.delete(msg.id);
|
|
89
|
+
|
|
90
|
+
const durationMs = Date.now() - call.startTime;
|
|
91
|
+
const success = !msg.error;
|
|
92
|
+
|
|
93
|
+
writeQueue = writeQueue.then(async () => {
|
|
94
|
+
try {
|
|
95
|
+
const entry = createEntry(call.toolName, call.args, durationMs, success, lastHash);
|
|
96
|
+
const signed = sign(entry, secret);
|
|
97
|
+
lastHash = hashEntry(signed);
|
|
98
|
+
await appendFile(logPath, JSON.stringify(signed) + "\n");
|
|
99
|
+
} catch (err) {
|
|
100
|
+
process.stderr.write(`[mcp-audit] log write failed: ${err}\n`);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
clientRl.on("close", () => {
|
|
107
|
+
child.stdin!.end();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
process.on("SIGINT", () => {
|
|
111
|
+
child.kill("SIGINT");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
process.on("SIGTERM", () => {
|
|
115
|
+
child.kill("SIGTERM");
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function createEntry(
|
|
120
|
+
toolName: string,
|
|
121
|
+
args: Record<string, unknown>,
|
|
122
|
+
durationMs: number,
|
|
123
|
+
success: boolean,
|
|
124
|
+
previousHash: string,
|
|
125
|
+
): Omit<AuditEntry, "attestation"> {
|
|
126
|
+
return {
|
|
127
|
+
id: crypto.randomUUID(),
|
|
128
|
+
timestamp: new Date().toISOString(),
|
|
129
|
+
method: "tools/call",
|
|
130
|
+
toolName,
|
|
131
|
+
namespace: null,
|
|
132
|
+
upstream: null,
|
|
133
|
+
principal: null,
|
|
134
|
+
args,
|
|
135
|
+
durationMs,
|
|
136
|
+
success,
|
|
137
|
+
errorCode: null,
|
|
138
|
+
previousHash,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function sign(entry: Omit<AuditEntry, "attestation">, secret: Buffer): AuditEntry {
|
|
143
|
+
const canonical = JSON.stringify([
|
|
144
|
+
["id", entry.id],
|
|
145
|
+
["timestamp", entry.timestamp],
|
|
146
|
+
["method", entry.method],
|
|
147
|
+
["toolName", entry.toolName],
|
|
148
|
+
["namespace", null],
|
|
149
|
+
["upstream", null],
|
|
150
|
+
["principal", null],
|
|
151
|
+
["durationMs", entry.durationMs],
|
|
152
|
+
["success", entry.success],
|
|
153
|
+
["errorCode", null],
|
|
154
|
+
["previousHash", entry.previousHash],
|
|
155
|
+
]);
|
|
156
|
+
const hmac = createHmac("sha256", secret);
|
|
157
|
+
hmac.update(canonical);
|
|
158
|
+
const attestation = hmac.digest("hex");
|
|
159
|
+
return { ...entry, attestation };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function hashEntry(entry: AuditEntry): string {
|
|
163
|
+
return createHash("sha256").update(JSON.stringify(entry)).digest("hex");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function ensureKey(keyPath: string): Promise<Buffer> {
|
|
167
|
+
try {
|
|
168
|
+
const hex = await readFile(keyPath, "utf-8");
|
|
169
|
+
return Buffer.from(hex.trim(), "hex");
|
|
170
|
+
} catch {
|
|
171
|
+
const key = randomBytes(32);
|
|
172
|
+
await appendFile(keyPath, key.toString("hex") + "\n");
|
|
173
|
+
process.stderr.write(`[mcp-audit] generated signing key: ${keyPath}\n`);
|
|
174
|
+
return key;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function restoreLastHash(logPath: string): Promise<string> {
|
|
179
|
+
try {
|
|
180
|
+
const s = await stat(logPath);
|
|
181
|
+
if (s.size === 0) return "genesis";
|
|
182
|
+
const content = await readFile(logPath, "utf-8");
|
|
183
|
+
const lines = content.trimEnd().split("\n");
|
|
184
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
185
|
+
try {
|
|
186
|
+
const entry: AuditEntry = JSON.parse(lines[i]);
|
|
187
|
+
return hashEntry(entry);
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
} catch {}
|
|
191
|
+
return "genesis";
|
|
192
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"resolveJsonModule": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*"],
|
|
17
|
+
"exclude": ["node_modules", "dist"]
|
|
18
|
+
}
|