@clampd/mcp-proxy 0.2.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/Dockerfile +32 -0
- package/README.md +103 -0
- package/dist/dashboard.d.ts +64 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +516 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/fleet.d.ts +52 -0
- package/dist/fleet.d.ts.map +1 -0
- package/dist/fleet.js +274 -0
- package/dist/fleet.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +173 -0
- package/dist/index.js.map +1 -0
- package/dist/interceptor.d.ts +92 -0
- package/dist/interceptor.d.ts.map +1 -0
- package/dist/interceptor.js +274 -0
- package/dist/interceptor.js.map +1 -0
- package/dist/logger.d.ts +6 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +15 -0
- package/dist/logger.js.map +1 -0
- package/dist/mock-server.d.ts +14 -0
- package/dist/mock-server.d.ts.map +1 -0
- package/dist/mock-server.js +128 -0
- package/dist/mock-server.js.map +1 -0
- package/dist/proxy.d.ts +59 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +578 -0
- package/dist/proxy.js.map +1 -0
- package/fleet.example.json +38 -0
- package/package.json +44 -0
- package/src/dashboard.ts +602 -0
- package/src/fleet.ts +329 -0
- package/src/index.ts +187 -0
- package/src/interceptor.ts +427 -0
- package/src/logger.ts +17 -0
- package/src/mock-server.ts +240 -0
- package/src/proxy.ts +752 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway communication — classifies tool calls, scans input/output,
|
|
3
|
+
* and validates responses through ag-gateway.
|
|
4
|
+
*
|
|
5
|
+
* This is the security boundary: every MCP tool call passes through here
|
|
6
|
+
* before reaching the upstream server.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHmac, createHash } from "node:crypto";
|
|
10
|
+
import { log } from "./logger.js";
|
|
11
|
+
|
|
12
|
+
// ── Types ─────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface ClassifyResult {
|
|
15
|
+
/** Whether the tool call is allowed */
|
|
16
|
+
allowed: boolean;
|
|
17
|
+
/** Risk score from the 9-stage pipeline (0.0 - 1.0) */
|
|
18
|
+
risk_score: number;
|
|
19
|
+
/** Rule IDs that matched (e.g. ["R001", "R006"]) */
|
|
20
|
+
matched_rules: string[];
|
|
21
|
+
/** Human-readable denial reason (when blocked) */
|
|
22
|
+
denial_reason?: string;
|
|
23
|
+
/** Scope granted by OPA policy */
|
|
24
|
+
scope_granted?: string;
|
|
25
|
+
/** Gateway request ID for audit trail */
|
|
26
|
+
request_id?: string;
|
|
27
|
+
/** Scope token for response validation */
|
|
28
|
+
scope_token?: string;
|
|
29
|
+
/** Gateway processing latency */
|
|
30
|
+
latency_ms: number;
|
|
31
|
+
/** Pipeline stages that were degraded */
|
|
32
|
+
degraded_stages: string[];
|
|
33
|
+
/** Behavioral session flags */
|
|
34
|
+
session_flags: string[];
|
|
35
|
+
/** Intent action: "pass", "flag", or "block" */
|
|
36
|
+
action?: string;
|
|
37
|
+
/** Human-readable reasoning from the rules engine */
|
|
38
|
+
reasoning?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ScanResult {
|
|
42
|
+
allowed: boolean;
|
|
43
|
+
risk_score: number;
|
|
44
|
+
matched_rules: string[];
|
|
45
|
+
denial_reason?: string;
|
|
46
|
+
latency_ms: number;
|
|
47
|
+
pii_found?: Array<{ pii_type: string; count: number }>;
|
|
48
|
+
secrets_found?: Array<{ secret_type: string; count: number }>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface InspectResult {
|
|
52
|
+
allowed: boolean;
|
|
53
|
+
risk_score: number;
|
|
54
|
+
matched_rules: string[];
|
|
55
|
+
denial_reason?: string;
|
|
56
|
+
latency_ms: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Tool definition from MCP listTools() */
|
|
60
|
+
export interface ToolDef {
|
|
61
|
+
name: string;
|
|
62
|
+
description?: string;
|
|
63
|
+
inputSchema?: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Tool descriptor hashing ──────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** Compute SHA-256 hex hash of a tool descriptor for rug-pull detection. */
|
|
69
|
+
export function computeToolDescriptorHash(tool: ToolDef): string {
|
|
70
|
+
const canonical = JSON.stringify({
|
|
71
|
+
name: tool.name,
|
|
72
|
+
description: tool.description ?? "",
|
|
73
|
+
inputSchema: tool.inputSchema ?? {},
|
|
74
|
+
});
|
|
75
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Build a lookup map of tool name → descriptor hash. */
|
|
79
|
+
export function buildDescriptorMap(tools: ToolDef[]): Map<string, string> {
|
|
80
|
+
const map = new Map<string, string>();
|
|
81
|
+
for (const tool of tools) {
|
|
82
|
+
map.set(tool.name, computeToolDescriptorHash(tool));
|
|
83
|
+
}
|
|
84
|
+
return map;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── JWT generation ────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function makeAgentJwt(agentId: string, secret: string): string {
|
|
90
|
+
if (!secret) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
"No JWT signing secret available. " +
|
|
93
|
+
"Set JWT_SECRET env var or pass --secret flag."
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Derive signing key: ags_ prefixed secrets get SHA-256 hashed
|
|
98
|
+
// to match the credential_hash stored server-side in Redis.
|
|
99
|
+
const signingKey = secret.startsWith("ags_")
|
|
100
|
+
? createHash("sha256").update(secret).digest("hex")
|
|
101
|
+
: secret;
|
|
102
|
+
|
|
103
|
+
const header = { alg: "HS256", typ: "JWT" };
|
|
104
|
+
const payload = {
|
|
105
|
+
sub: agentId,
|
|
106
|
+
iat: Math.floor(Date.now() / 1000),
|
|
107
|
+
exp: Math.floor(Date.now() / 1000) + 300, // 5 minutes
|
|
108
|
+
iss: "clampd-mcp-proxy",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const encode = (obj: Record<string, unknown>) =>
|
|
112
|
+
Buffer.from(JSON.stringify(obj)).toString("base64url");
|
|
113
|
+
|
|
114
|
+
const headerB64 = encode(header);
|
|
115
|
+
const payloadB64 = encode(payload);
|
|
116
|
+
const signature = createHmac("sha256", signingKey)
|
|
117
|
+
.update(`${headerB64}.${payloadB64}`)
|
|
118
|
+
.digest("base64url");
|
|
119
|
+
|
|
120
|
+
return `${headerB64}.${payloadB64}.${signature}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Shared helpers ───────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
function makeHeaders(apiKey: string, agentId: string, secret: string, authorizedTools?: string[]): Record<string, string> {
|
|
126
|
+
const jwt = makeAgentJwt(agentId, secret);
|
|
127
|
+
const h: Record<string, string> = {
|
|
128
|
+
"Content-Type": "application/json",
|
|
129
|
+
"X-AG-Key": apiKey,
|
|
130
|
+
"Authorization": `Bearer ${jwt}`,
|
|
131
|
+
};
|
|
132
|
+
if (authorizedTools && authorizedTools.length > 0) {
|
|
133
|
+
h["X-AG-Authorized-Tools"] = authorizedTools.join(",");
|
|
134
|
+
}
|
|
135
|
+
return h;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function baseUrl(gatewayUrl: string): string {
|
|
139
|
+
return gatewayUrl.replace(/\/$/, "");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Gateway classify ─────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
export async function classifyToolCall(
|
|
145
|
+
gatewayUrl: string,
|
|
146
|
+
apiKey: string,
|
|
147
|
+
secret: string,
|
|
148
|
+
agentId: string,
|
|
149
|
+
toolName: string,
|
|
150
|
+
params: Record<string, unknown>,
|
|
151
|
+
dryRun = false,
|
|
152
|
+
toolDescriptorHash?: string,
|
|
153
|
+
authorizedTools?: string[],
|
|
154
|
+
toolDescription?: string,
|
|
155
|
+
toolParamsSchema?: string,
|
|
156
|
+
): Promise<ClassifyResult> {
|
|
157
|
+
const endpoint = dryRun ? "/v1/verify" : "/v1/proxy";
|
|
158
|
+
const url = `${baseUrl(gatewayUrl)}${endpoint}`;
|
|
159
|
+
|
|
160
|
+
// MCP proxy handles upstream forwarding itself — use evaluate-only
|
|
161
|
+
// mode (empty target_url) so the gateway runs stages 1-6 and returns
|
|
162
|
+
// the allow/deny verdict without attempting to forward to mcp://.
|
|
163
|
+
const body: Record<string, unknown> = {
|
|
164
|
+
tool: toolName,
|
|
165
|
+
params,
|
|
166
|
+
target_url: "",
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (!dryRun) {
|
|
170
|
+
body.prompt_context = `MCP tool call: ${toolName}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (toolDescriptorHash) {
|
|
174
|
+
body.tool_descriptor_hash = toolDescriptorHash;
|
|
175
|
+
}
|
|
176
|
+
if (toolDescription) {
|
|
177
|
+
body.tool_description = toolDescription;
|
|
178
|
+
}
|
|
179
|
+
if (toolParamsSchema) {
|
|
180
|
+
body.tool_params_schema = toolParamsSchema;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
log("debug", `POST ${url} — tool=${toolName}`);
|
|
184
|
+
|
|
185
|
+
const resp = await fetch(url, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: makeHeaders(apiKey, agentId, secret, authorizedTools),
|
|
188
|
+
body: JSON.stringify(body),
|
|
189
|
+
signal: AbortSignal.timeout(5_000),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (!resp.ok) {
|
|
193
|
+
const text = await resp.text().catch(() => `HTTP ${resp.status}`);
|
|
194
|
+
throw new Error(`Gateway returned ${resp.status}: ${text}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
allowed: json.allowed as boolean,
|
|
201
|
+
risk_score: (json.risk_score as number) ?? 0,
|
|
202
|
+
matched_rules: (json.matched_rules as string[]) ?? [],
|
|
203
|
+
denial_reason: json.denial_reason as string | undefined,
|
|
204
|
+
scope_granted: json.scope_granted as string | undefined,
|
|
205
|
+
request_id: json.request_id as string | undefined,
|
|
206
|
+
scope_token: json.scope_token as string | undefined,
|
|
207
|
+
latency_ms: (json.latency_ms as number) ?? 0,
|
|
208
|
+
degraded_stages: (json.degraded_stages as string[]) ?? [],
|
|
209
|
+
session_flags: (json.session_flags as string[]) ?? [],
|
|
210
|
+
action: json.action as string | undefined,
|
|
211
|
+
reasoning: json.reasoning as string | undefined,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Input scanning ───────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
export async function scanInput(
|
|
218
|
+
gatewayUrl: string,
|
|
219
|
+
apiKey: string,
|
|
220
|
+
secret: string,
|
|
221
|
+
agentId: string,
|
|
222
|
+
text: string,
|
|
223
|
+
): Promise<ScanResult> {
|
|
224
|
+
const url = `${baseUrl(gatewayUrl)}/v1/scan-input`;
|
|
225
|
+
|
|
226
|
+
log("debug", `POST ${url} — scan-input (${text.length} chars)`);
|
|
227
|
+
|
|
228
|
+
const resp = await fetch(url, {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: makeHeaders(apiKey, agentId, secret),
|
|
231
|
+
body: JSON.stringify({ text }),
|
|
232
|
+
signal: AbortSignal.timeout(5_000),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (!resp.ok) {
|
|
236
|
+
const body = await resp.text().catch(() => `HTTP ${resp.status}`);
|
|
237
|
+
throw new Error(`scan-input returned ${resp.status}: ${body}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
241
|
+
return {
|
|
242
|
+
allowed: json.allowed as boolean,
|
|
243
|
+
risk_score: (json.risk_score as number) ?? 0,
|
|
244
|
+
matched_rules: (json.matched_rules as string[]) ?? [],
|
|
245
|
+
denial_reason: json.denial_reason as string | undefined,
|
|
246
|
+
latency_ms: (json.latency_ms as number) ?? 0,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Output scanning ──────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
export async function scanOutput(
|
|
253
|
+
gatewayUrl: string,
|
|
254
|
+
apiKey: string,
|
|
255
|
+
secret: string,
|
|
256
|
+
agentId: string,
|
|
257
|
+
text: string,
|
|
258
|
+
requestId?: string,
|
|
259
|
+
): Promise<ScanResult> {
|
|
260
|
+
const url = `${baseUrl(gatewayUrl)}/v1/scan-output`;
|
|
261
|
+
|
|
262
|
+
log("debug", `POST ${url} — scan-output (${text.length} chars)`);
|
|
263
|
+
|
|
264
|
+
const body: Record<string, unknown> = { text };
|
|
265
|
+
if (requestId) body.request_id = requestId;
|
|
266
|
+
|
|
267
|
+
const resp = await fetch(url, {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers: makeHeaders(apiKey, agentId, secret),
|
|
270
|
+
body: JSON.stringify(body),
|
|
271
|
+
signal: AbortSignal.timeout(5_000),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (!resp.ok) {
|
|
275
|
+
const respBody = await resp.text().catch(() => `HTTP ${resp.status}`);
|
|
276
|
+
throw new Error(`scan-output returned ${resp.status}: ${respBody}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
280
|
+
return {
|
|
281
|
+
allowed: json.allowed as boolean,
|
|
282
|
+
risk_score: (json.risk_score as number) ?? 0,
|
|
283
|
+
matched_rules: (json.matched_rules as string[]) ?? [],
|
|
284
|
+
denial_reason: json.denial_reason as string | undefined,
|
|
285
|
+
latency_ms: (json.latency_ms as number) ?? 0,
|
|
286
|
+
pii_found: json.pii_found as ScanResult["pii_found"],
|
|
287
|
+
secrets_found: json.secrets_found as ScanResult["secrets_found"],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Response inspection ──────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
export async function inspectResponse(
|
|
294
|
+
gatewayUrl: string,
|
|
295
|
+
apiKey: string,
|
|
296
|
+
secret: string,
|
|
297
|
+
agentId: string,
|
|
298
|
+
toolName: string,
|
|
299
|
+
responseData: unknown,
|
|
300
|
+
requestId?: string,
|
|
301
|
+
scopeToken?: string,
|
|
302
|
+
): Promise<InspectResult> {
|
|
303
|
+
const url = `${baseUrl(gatewayUrl)}/v1/inspect`;
|
|
304
|
+
|
|
305
|
+
log("debug", `POST ${url} — inspect response for ${toolName}`);
|
|
306
|
+
|
|
307
|
+
const body: Record<string, unknown> = {
|
|
308
|
+
tool: toolName,
|
|
309
|
+
response_data: responseData,
|
|
310
|
+
};
|
|
311
|
+
if (requestId) body.request_id = requestId;
|
|
312
|
+
if (scopeToken) body.scope_token = scopeToken;
|
|
313
|
+
|
|
314
|
+
const resp = await fetch(url, {
|
|
315
|
+
method: "POST",
|
|
316
|
+
headers: makeHeaders(apiKey, agentId, secret),
|
|
317
|
+
body: JSON.stringify(body),
|
|
318
|
+
signal: AbortSignal.timeout(5_000),
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
if (!resp.ok) {
|
|
322
|
+
const respBody = await resp.text().catch(() => `HTTP ${resp.status}`);
|
|
323
|
+
throw new Error(`inspect returned ${resp.status}: ${respBody}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
327
|
+
return {
|
|
328
|
+
allowed: json.allowed as boolean,
|
|
329
|
+
risk_score: (json.risk_score as number) ?? 0,
|
|
330
|
+
matched_rules: (json.matched_rules as string[]) ?? [],
|
|
331
|
+
denial_reason: json.denial_reason as string | undefined,
|
|
332
|
+
latency_ms: (json.latency_ms as number) ?? 0,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── Startup tool registration ───────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
export interface RegisterToolsResult {
|
|
339
|
+
registered: number;
|
|
340
|
+
failed: number;
|
|
341
|
+
errors: Array<{ tool: string; error: string }>;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Register discovered tools with the gateway at startup.
|
|
346
|
+
*
|
|
347
|
+
* Sends a lightweight `/v1/proxy` call (evaluate-only, target_url="") for
|
|
348
|
+
* each tool with benign empty params. This triggers the shadow event pipeline
|
|
349
|
+
* in ag-gateway, which ag-control picks up to auto-capture tool descriptors
|
|
350
|
+
* into the `tool_descriptors` table. If the org has `auto_trust` enabled,
|
|
351
|
+
* tools are auto-approved and their scopes synced to Redis — preventing
|
|
352
|
+
* `tool_not_registered` (422) errors on subsequent real calls.
|
|
353
|
+
*
|
|
354
|
+
* Requests run in parallel with a concurrency cap to avoid overwhelming
|
|
355
|
+
* the gateway. Registration is idempotent (safe to re-run on restart).
|
|
356
|
+
*/
|
|
357
|
+
export async function registerTools(
|
|
358
|
+
gatewayUrl: string,
|
|
359
|
+
apiKey: string,
|
|
360
|
+
secret: string,
|
|
361
|
+
agentId: string,
|
|
362
|
+
tools: ToolDef[],
|
|
363
|
+
descriptorMap: Map<string, string>,
|
|
364
|
+
): Promise<RegisterToolsResult> {
|
|
365
|
+
const url = `${baseUrl(gatewayUrl)}/v1/proxy`;
|
|
366
|
+
const toolNames = tools.map((t) => t.name);
|
|
367
|
+
const result: RegisterToolsResult = { registered: 0, failed: 0, errors: [] };
|
|
368
|
+
|
|
369
|
+
// Process in batches of 5 to avoid overwhelming the gateway
|
|
370
|
+
const BATCH_SIZE = 5;
|
|
371
|
+
for (let i = 0; i < tools.length; i += BATCH_SIZE) {
|
|
372
|
+
const batch = tools.slice(i, i + BATCH_SIZE);
|
|
373
|
+
const promises = batch.map(async (tool) => {
|
|
374
|
+
const body: Record<string, unknown> = {
|
|
375
|
+
tool: tool.name,
|
|
376
|
+
params: {},
|
|
377
|
+
target_url: "",
|
|
378
|
+
prompt_context: `MCP startup registration: ${tool.name}`,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const hash = descriptorMap.get(tool.name);
|
|
382
|
+
if (hash) {
|
|
383
|
+
body.tool_descriptor_hash = hash;
|
|
384
|
+
}
|
|
385
|
+
if (tool.description) {
|
|
386
|
+
body.tool_description = tool.description;
|
|
387
|
+
}
|
|
388
|
+
if (tool.inputSchema) {
|
|
389
|
+
body.tool_params_schema = JSON.stringify(tool.inputSchema);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
const resp = await fetch(url, {
|
|
394
|
+
method: "POST",
|
|
395
|
+
headers: makeHeaders(apiKey, agentId, secret, toolNames),
|
|
396
|
+
body: JSON.stringify(body),
|
|
397
|
+
signal: AbortSignal.timeout(10_000),
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
if (!resp.ok) {
|
|
401
|
+
const text = await resp.text().catch(() => `HTTP ${resp.status}`);
|
|
402
|
+
// 422 tool_not_registered is expected when auto_trust is off —
|
|
403
|
+
// the shadow event was still emitted, so discovery still works.
|
|
404
|
+
if (resp.status === 422) {
|
|
405
|
+
result.registered++;
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
result.failed++;
|
|
409
|
+
result.errors.push({ tool: tool.name, error: `${resp.status}: ${text}` });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
result.registered++;
|
|
414
|
+
} catch (err: unknown) {
|
|
415
|
+
result.failed++;
|
|
416
|
+
result.errors.push({
|
|
417
|
+
tool: tool.name,
|
|
418
|
+
error: err instanceof Error ? err.message : String(err),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
await Promise.all(promises);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return result;
|
|
427
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal logger that writes to stderr (to avoid corrupting MCP stdio streams).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
let verbose = false;
|
|
6
|
+
|
|
7
|
+
export function setVerbose(v: boolean): void {
|
|
8
|
+
verbose = v;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function log(level: "info" | "warn" | "error" | "debug", msg: string): void {
|
|
12
|
+
if (level === "debug" && !verbose) return;
|
|
13
|
+
|
|
14
|
+
const ts = new Date().toISOString();
|
|
15
|
+
const prefix = `[${ts}] [clampd-mcp-proxy] [${level}]`;
|
|
16
|
+
process.stderr.write(`${prefix} ${msg}\n`);
|
|
17
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Mock MCP server with role-specific tool sets.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node mock-server.js analyst → data analysis tools
|
|
7
|
+
* node mock-server.js devops → infrastructure tools
|
|
8
|
+
* node mock-server.js dbadmin → database tools
|
|
9
|
+
* node mock-server.js all → every tool (default)
|
|
10
|
+
*
|
|
11
|
+
* Runs over stdio so the MCP proxy can spawn it as an upstream.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
|
|
18
|
+
const role = process.argv[2] ?? "all";
|
|
19
|
+
|
|
20
|
+
const server = new McpServer({
|
|
21
|
+
name: `clampd-mock-${role}`,
|
|
22
|
+
version: "1.0.0",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// ── Database tools ──────────────────────────────────────────────
|
|
26
|
+
const dbTools = () => {
|
|
27
|
+
server.tool(
|
|
28
|
+
"database_query",
|
|
29
|
+
"Execute a SQL query against the database",
|
|
30
|
+
{ query: z.string().describe("SQL query to execute") },
|
|
31
|
+
async ({ query }) => ({
|
|
32
|
+
content: [{ type: "text", text: `[mock] Query result for: ${query}\n\n| id | name | email |\n|----|------|-------|\n| 1 | Alice | alice@example.com |\n| 2 | Bob | bob@example.com |` }],
|
|
33
|
+
})
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
server.tool(
|
|
37
|
+
"database_schema",
|
|
38
|
+
"Get database schema information",
|
|
39
|
+
{ table: z.string().optional().describe("Table name (omit for all tables)") },
|
|
40
|
+
async ({ table }) => ({
|
|
41
|
+
content: [{ type: "text", text: `[mock] Schema for ${table ?? "all tables"}:\n\nusers (id INT PK, name VARCHAR, email VARCHAR, ssn VARCHAR)\norders (id INT PK, user_id INT FK, total DECIMAL)\npayments (id INT PK, card_number VARCHAR, cvv VARCHAR)` }],
|
|
42
|
+
})
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
server.tool(
|
|
46
|
+
"database_mutate",
|
|
47
|
+
"Execute a write/mutation query (INSERT, UPDATE, DELETE)",
|
|
48
|
+
{ query: z.string().describe("SQL mutation to execute") },
|
|
49
|
+
async ({ query }) => ({
|
|
50
|
+
content: [{ type: "text", text: `[mock] Mutation executed: ${query}\nRows affected: 1` }],
|
|
51
|
+
})
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// ── Shell / Infrastructure tools ────────────────────────────────
|
|
56
|
+
const shellTools = () => {
|
|
57
|
+
server.tool(
|
|
58
|
+
"shell_exec",
|
|
59
|
+
"Execute a shell command on the server",
|
|
60
|
+
{ command: z.string().describe("Shell command to execute") },
|
|
61
|
+
async ({ command }) => ({
|
|
62
|
+
content: [{ type: "text", text: `[mock] $ ${command}\nCommand output simulated. Exit code: 0` }],
|
|
63
|
+
})
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
server.tool(
|
|
67
|
+
"process_list",
|
|
68
|
+
"List running processes",
|
|
69
|
+
{ filter: z.string().optional().describe("Process name filter") },
|
|
70
|
+
async ({ filter }) => ({
|
|
71
|
+
content: [{ type: "text", text: `[mock] PID CMD\n1 systemd\n142 nginx\n203 postgres\n${filter ? `(filtered by: ${filter})` : ""}` }],
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
server.tool(
|
|
76
|
+
"docker_exec",
|
|
77
|
+
"Execute a command inside a Docker container",
|
|
78
|
+
{
|
|
79
|
+
container: z.string().describe("Container name or ID"),
|
|
80
|
+
command: z.string().describe("Command to execute"),
|
|
81
|
+
},
|
|
82
|
+
async ({ container, command }) => ({
|
|
83
|
+
content: [{ type: "text", text: `[mock] docker exec ${container} ${command}\nOutput simulated.` }],
|
|
84
|
+
})
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
server.tool(
|
|
88
|
+
"kubernetes_apply",
|
|
89
|
+
"Apply a Kubernetes manifest",
|
|
90
|
+
{ manifest: z.string().describe("YAML manifest content") },
|
|
91
|
+
async ({ manifest }) => ({
|
|
92
|
+
content: [{ type: "text", text: `[mock] kubectl apply -f -\n${manifest.substring(0, 100)}...\nresource/configured` }],
|
|
93
|
+
})
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ── Filesystem tools ────────────────────────────────────────────
|
|
98
|
+
const fsTools = () => {
|
|
99
|
+
server.tool(
|
|
100
|
+
"filesystem_read",
|
|
101
|
+
"Read contents of a file",
|
|
102
|
+
{ path: z.string().describe("File path to read") },
|
|
103
|
+
async ({ path }) => ({
|
|
104
|
+
content: [{ type: "text", text: `[mock] Contents of ${path}:\nSample file content here.` }],
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
server.tool(
|
|
109
|
+
"filesystem_write",
|
|
110
|
+
"Write content to a file",
|
|
111
|
+
{
|
|
112
|
+
path: z.string().describe("File path to write"),
|
|
113
|
+
content: z.string().describe("Content to write"),
|
|
114
|
+
},
|
|
115
|
+
async ({ path, content }) => ({
|
|
116
|
+
content: [{ type: "text", text: `[mock] Wrote ${content.length} bytes to ${path}` }],
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
server.tool(
|
|
121
|
+
"filesystem_list",
|
|
122
|
+
"List files in a directory",
|
|
123
|
+
{ path: z.string().describe("Directory path") },
|
|
124
|
+
async ({ path }) => ({
|
|
125
|
+
content: [{ type: "text", text: `[mock] ${path}/\n config.yml\n data.csv\n README.md\n .env\n credentials.json` }],
|
|
126
|
+
})
|
|
127
|
+
);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// ── Network / HTTP tools ────────────────────────────────────────
|
|
131
|
+
const netTools = () => {
|
|
132
|
+
server.tool(
|
|
133
|
+
"http_fetch",
|
|
134
|
+
"Make an HTTP request to a URL",
|
|
135
|
+
{
|
|
136
|
+
url: z.string().describe("URL to fetch"),
|
|
137
|
+
method: z.string().optional().describe("HTTP method (GET, POST, etc.)"),
|
|
138
|
+
},
|
|
139
|
+
async ({ url, method }) => ({
|
|
140
|
+
content: [{ type: "text", text: `[mock] ${method ?? "GET"} ${url}\nStatus: 200 OK\nBody: {"status": "ok"}` }],
|
|
141
|
+
})
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
server.tool(
|
|
145
|
+
"network_scan",
|
|
146
|
+
"Scan a network range for open ports",
|
|
147
|
+
{
|
|
148
|
+
target: z.string().describe("IP or CIDR range to scan"),
|
|
149
|
+
ports: z.string().optional().describe("Port range (e.g., 1-1024)"),
|
|
150
|
+
},
|
|
151
|
+
async ({ target, ports }) => ({
|
|
152
|
+
content: [{ type: "text", text: `[mock] Scanning ${target} ports ${ports ?? "1-1024"}\n22/tcp open ssh\n80/tcp open http\n443/tcp open https` }],
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// ── Email / Communication tools ─────────────────────────────────
|
|
158
|
+
const emailTools = () => {
|
|
159
|
+
server.tool(
|
|
160
|
+
"email_send",
|
|
161
|
+
"Send an email message",
|
|
162
|
+
{
|
|
163
|
+
to: z.string().describe("Recipient email address"),
|
|
164
|
+
subject: z.string().describe("Email subject"),
|
|
165
|
+
body: z.string().describe("Email body content"),
|
|
166
|
+
},
|
|
167
|
+
async ({ to, subject }) => ({
|
|
168
|
+
content: [{ type: "text", text: `[mock] Email sent to ${to}\nSubject: ${subject}\nStatus: delivered` }],
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
server.tool(
|
|
173
|
+
"email_search",
|
|
174
|
+
"Search emails by query",
|
|
175
|
+
{ query: z.string().describe("Search query") },
|
|
176
|
+
async ({ query }) => ({
|
|
177
|
+
content: [{ type: "text", text: `[mock] Search results for "${query}":\n1. Meeting notes - from boss@company.com\n2. Invoice #1234 - from billing@vendor.com` }],
|
|
178
|
+
})
|
|
179
|
+
);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// ── LLM / AI tools ──────────────────────────────────────────────
|
|
183
|
+
const llmTools = () => {
|
|
184
|
+
server.tool(
|
|
185
|
+
"llm_prompt",
|
|
186
|
+
"Send a prompt to an LLM and get a response",
|
|
187
|
+
{
|
|
188
|
+
prompt: z.string().describe("The prompt to send"),
|
|
189
|
+
model: z.string().optional().describe("Model to use"),
|
|
190
|
+
},
|
|
191
|
+
async ({ prompt, model }) => ({
|
|
192
|
+
content: [{ type: "text", text: `[mock] ${model ?? "default"} response to: "${prompt.substring(0, 80)}..."\n\nThis is a simulated LLM response.` }],
|
|
193
|
+
})
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
server.tool(
|
|
197
|
+
"llm_embed",
|
|
198
|
+
"Generate embeddings for text",
|
|
199
|
+
{ text: z.string().describe("Text to embed") },
|
|
200
|
+
async ({ text }) => ({
|
|
201
|
+
content: [{ type: "text", text: `[mock] Embedding for "${text.substring(0, 50)}...":\n[0.0234, -0.1456, 0.8901, ...] (1536 dimensions)` }],
|
|
202
|
+
})
|
|
203
|
+
);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// ── Auth / Secrets tools ────────────────────────────────────────
|
|
207
|
+
const authTools = () => {
|
|
208
|
+
server.tool(
|
|
209
|
+
"secret_read",
|
|
210
|
+
"Read a secret from the vault",
|
|
211
|
+
{ key: z.string().describe("Secret key name") },
|
|
212
|
+
async ({ key }) => ({
|
|
213
|
+
content: [{ type: "text", text: `[mock] Secret "${key}": ****redacted****` }],
|
|
214
|
+
})
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
server.tool(
|
|
218
|
+
"credential_rotate",
|
|
219
|
+
"Rotate a credential or API key",
|
|
220
|
+
{ service: z.string().describe("Service name") },
|
|
221
|
+
async ({ service }) => ({
|
|
222
|
+
content: [{ type: "text", text: `[mock] Rotated credential for ${service}. New key: ak_****new****` }],
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// ── Register tools by role ──────────────────────────────────────
|
|
228
|
+
const roleMap: Record<string, (() => void)[]> = {
|
|
229
|
+
analyst: [dbTools, netTools, llmTools, emailTools],
|
|
230
|
+
devops: [shellTools, fsTools, netTools, authTools],
|
|
231
|
+
dbadmin: [dbTools, fsTools, authTools],
|
|
232
|
+
all: [dbTools, shellTools, fsTools, netTools, emailTools, llmTools, authTools],
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const register = roleMap[role] ?? roleMap.all;
|
|
236
|
+
for (const fn of register) fn();
|
|
237
|
+
|
|
238
|
+
// ── Start ───────────────────────────────────────────────────────
|
|
239
|
+
const transport = new StdioServerTransport();
|
|
240
|
+
await server.connect(transport);
|