@blekline/mcp-proxy 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/LICENSE +2 -0
- package/README.md +26 -0
- package/dist/downstream/mcp-client.js +77 -0
- package/dist/enforcement/scan-tool-args.js +24 -0
- package/dist/index.js +63 -0
- package/dist/mcp-proxy.js +70 -0
- package/dist/mcp-proxy.test.js +21 -0
- package/package.json +34 -0
- package/src/downstream/mcp-client.ts +88 -0
- package/src/enforcement/scan-tool-args.ts +35 -0
- package/src/index.ts +82 -0
- package/src/mcp-proxy.test.ts +23 -0
- package/src/mcp-proxy.ts +90 -0
- package/tsconfig.json +12 -0
package/LICENSE
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @blekline/mcp-proxy
|
|
2
|
+
|
|
3
|
+
MCP proxy router that intercepts downstream tool calls, runs Blekline enforcement (mask/block), then forwards approved calls to a downstream MCP server (e.g. Daytona sandbox).
|
|
4
|
+
|
|
5
|
+
## Flow
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Model → @blekline/mcp-proxy → POST /api/mcp/enforce-tool-call → downstream MCP
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Env
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
BLEKLINE_WORKSPACE_TOKEN=ws_...
|
|
15
|
+
BLEKLINE_DOWNSTREAM_MCP_COMMAND=... # optional mock or real Daytona MCP
|
|
16
|
+
BLEKLINE_CLIENT_SURFACE=cursor
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Run
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm --filter @blekline/mcp-proxy build
|
|
23
|
+
pnpm --filter @blekline/mcp-proxy test
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Smoke test: `pnpm demo:mcp-smoke` from repo root.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4
|
+
/** Mock downstream tools for demo without Daytona API key. */
|
|
5
|
+
export const MOCK_DOWNSTREAM_TOOLS = [
|
|
6
|
+
{
|
|
7
|
+
name: "run_shell",
|
|
8
|
+
description: "Run a shell command in sandbox (mock)",
|
|
9
|
+
inputSchema: {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: { command: { type: "string" } },
|
|
12
|
+
required: ["command"],
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: "write_file",
|
|
17
|
+
description: "Write file in sandbox (mock)",
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: { path: { type: "string" }, content: { type: "string" } },
|
|
21
|
+
required: ["path", "content"],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
export async function listDownstreamTools() {
|
|
26
|
+
if (process.env.BLEKLINE_MCP_PROXY_MOCK === "1") {
|
|
27
|
+
return MOCK_DOWNSTREAM_TOOLS;
|
|
28
|
+
}
|
|
29
|
+
const cmd = process.env.BLEKLINE_DOWNSTREAM_MCP_COMMAND?.trim();
|
|
30
|
+
if (!cmd)
|
|
31
|
+
return MOCK_DOWNSTREAM_TOOLS;
|
|
32
|
+
const parts = cmd.split(",").map((s) => s.trim()).filter(Boolean);
|
|
33
|
+
if (parts.length === 0)
|
|
34
|
+
return MOCK_DOWNSTREAM_TOOLS;
|
|
35
|
+
const [command, ...args] = parts;
|
|
36
|
+
const transport = new StdioClientTransport({ command, args, env: process.env });
|
|
37
|
+
const client = new Client({ name: "blekline-proxy-downstream", version: "0.1.0" }, { capabilities: {} });
|
|
38
|
+
await client.connect(transport);
|
|
39
|
+
const listed = await client.listTools();
|
|
40
|
+
await client.close();
|
|
41
|
+
return listed.tools.map((t) => ({
|
|
42
|
+
name: t.name,
|
|
43
|
+
description: t.description,
|
|
44
|
+
inputSchema: t.inputSchema,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
export async function callDownstreamTool(name, args) {
|
|
48
|
+
if (process.env.BLEKLINE_MCP_PROXY_MOCK === "1") {
|
|
49
|
+
return { ok: true, mock: true, tool: name, received: args };
|
|
50
|
+
}
|
|
51
|
+
const cmd = process.env.BLEKLINE_DOWNSTREAM_MCP_COMMAND?.trim();
|
|
52
|
+
if (!cmd) {
|
|
53
|
+
return { ok: true, mock: true, tool: name, received: args };
|
|
54
|
+
}
|
|
55
|
+
const parts = cmd.split(",").map((s) => s.trim()).filter(Boolean);
|
|
56
|
+
const [command, ...spawnArgs] = parts;
|
|
57
|
+
const transport = new StdioClientTransport({
|
|
58
|
+
command,
|
|
59
|
+
args: spawnArgs,
|
|
60
|
+
env: process.env,
|
|
61
|
+
});
|
|
62
|
+
const client = new Client({ name: "blekline-proxy-downstream", version: "0.1.0" }, { capabilities: {} });
|
|
63
|
+
await client.connect(transport);
|
|
64
|
+
const result = await client.callTool({ name, arguments: args });
|
|
65
|
+
await client.close();
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
/** Spawn helper for health checks (unused in hot path). */
|
|
69
|
+
export function spawnDownstreamCheck() {
|
|
70
|
+
const cmd = process.env.BLEKLINE_DOWNSTREAM_MCP_COMMAND?.trim();
|
|
71
|
+
if (!cmd)
|
|
72
|
+
return;
|
|
73
|
+
const parts = cmd.split(",").map((s) => s.trim()).filter(Boolean);
|
|
74
|
+
if (parts.length === 0)
|
|
75
|
+
return;
|
|
76
|
+
spawn(parts[0], parts.slice(1), { stdio: "ignore" });
|
|
77
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { scanTextForSecrets } from "@blekline/contracts";
|
|
2
|
+
const DESTRUCTIVE_RE = /\brm\s+-rf\b|\bformat\s+c:\b|\bdrop\s+database\b/i;
|
|
3
|
+
/** Fast local scan of MCP tool arguments (used before cloud enforce-tool-call). */
|
|
4
|
+
export function scanToolArgs(args) {
|
|
5
|
+
let blob = "";
|
|
6
|
+
try {
|
|
7
|
+
blob = JSON.stringify(args);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
blob = String(args);
|
|
11
|
+
}
|
|
12
|
+
const findings = [];
|
|
13
|
+
if (DESTRUCTIVE_RE.test(blob)) {
|
|
14
|
+
findings.push({ id: "destructive_command", label: "DESTRUCTIVE", field: "arguments" });
|
|
15
|
+
}
|
|
16
|
+
for (const s of scanTextForSecrets(blob)) {
|
|
17
|
+
findings.push({ id: s.id, label: s.label });
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
findings,
|
|
21
|
+
hasDestructive: DESTRUCTIVE_RE.test(blob),
|
|
22
|
+
secretCount: findings.filter((f) => f.id !== "destructive_command").length,
|
|
23
|
+
};
|
|
24
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { callDownstreamTool, listDownstreamTools } from "./downstream/mcp-client.js";
|
|
5
|
+
import { createProxyContext, interceptToolCall } from "./mcp-proxy.js";
|
|
6
|
+
const server = new Server({ name: "blekline-mcp-proxy", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
7
|
+
let ctx = null;
|
|
8
|
+
function getCtx() {
|
|
9
|
+
if (!ctx)
|
|
10
|
+
ctx = createProxyContext();
|
|
11
|
+
return ctx;
|
|
12
|
+
}
|
|
13
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
14
|
+
const downstream = await listDownstreamTools();
|
|
15
|
+
return {
|
|
16
|
+
tools: downstream.map((t) => ({
|
|
17
|
+
name: t.name,
|
|
18
|
+
description: t.description ?? `Proxied tool (Blekline governed): ${t.name}`,
|
|
19
|
+
inputSchema: t.inputSchema ?? { type: "object", properties: {} },
|
|
20
|
+
})),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
24
|
+
const toolName = request.params.name;
|
|
25
|
+
const toolArgs = (request.params.arguments ?? {});
|
|
26
|
+
const enforcement = await interceptToolCall(getCtx(), toolName, toolArgs);
|
|
27
|
+
if (!enforcement.ok) {
|
|
28
|
+
return {
|
|
29
|
+
content: [
|
|
30
|
+
{
|
|
31
|
+
type: "text",
|
|
32
|
+
text: JSON.stringify({
|
|
33
|
+
error: enforcement.message,
|
|
34
|
+
action: enforcement.action,
|
|
35
|
+
findings: enforcement.findings,
|
|
36
|
+
}, null, 2),
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
isError: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const downstreamResult = await callDownstreamTool(toolName, enforcement.arguments);
|
|
43
|
+
return {
|
|
44
|
+
content: [
|
|
45
|
+
{
|
|
46
|
+
type: "text",
|
|
47
|
+
text: JSON.stringify({
|
|
48
|
+
bleklineAction: enforcement.action,
|
|
49
|
+
entitiesMasked: enforcement.action === "mask" ? enforcement.entitiesMasked : 0,
|
|
50
|
+
result: downstreamResult,
|
|
51
|
+
}, null, 2),
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
async function main() {
|
|
57
|
+
const transport = new StdioServerTransport();
|
|
58
|
+
await server.connect(transport);
|
|
59
|
+
}
|
|
60
|
+
main().catch((err) => {
|
|
61
|
+
console.error("[blekline-mcp-proxy]", err);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { BleklineClient } from "@blekline/client";
|
|
3
|
+
import { enforceToolCallLocally } from "@blekline/contracts";
|
|
4
|
+
function envClientSurface() {
|
|
5
|
+
const v = process.env.BLEKLINE_CLIENT_SURFACE?.trim();
|
|
6
|
+
if (v === "cursor" || v === "claude-desktop" || v === "codex")
|
|
7
|
+
return v;
|
|
8
|
+
return "sdk";
|
|
9
|
+
}
|
|
10
|
+
export function createProxyContext() {
|
|
11
|
+
const token = process.env.BLEKLINE_WORKSPACE_TOKEN?.trim();
|
|
12
|
+
if (!token)
|
|
13
|
+
throw new Error("BLEKLINE_WORKSPACE_TOKEN is required");
|
|
14
|
+
return {
|
|
15
|
+
client: new BleklineClient({
|
|
16
|
+
baseUrl: process.env.BLEKLINE_API_URL?.trim(),
|
|
17
|
+
workspaceToken: token,
|
|
18
|
+
workspaceId: process.env.BLEKLINE_WORKSPACE_ID?.trim(),
|
|
19
|
+
metadata: { clientSurface: envClientSurface() },
|
|
20
|
+
}),
|
|
21
|
+
clientSurface: envClientSurface(),
|
|
22
|
+
useCloudEnforcement: process.env.BLEKLINE_PROXY_LOCAL_ONLY !== "1",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export async function interceptToolCall(ctx, toolName, toolArgs) {
|
|
26
|
+
const requestId = randomUUID();
|
|
27
|
+
let result = enforceToolCallLocally({ toolName, arguments: toolArgs, requestId });
|
|
28
|
+
if (ctx.useCloudEnforcement) {
|
|
29
|
+
try {
|
|
30
|
+
result = await ctx.client.enforceToolCall({
|
|
31
|
+
toolName,
|
|
32
|
+
arguments: toolArgs,
|
|
33
|
+
platform: "MCP-Proxy",
|
|
34
|
+
clientSurface: ctx.clientSurface,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* fall back to local result */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
void ctx.client
|
|
42
|
+
.emitEvent({
|
|
43
|
+
kind: "tool_call_enforcement",
|
|
44
|
+
platform: "MCP-Proxy",
|
|
45
|
+
entitiesMasked: result.entitiesMasked,
|
|
46
|
+
riskTier: result.riskTier,
|
|
47
|
+
action: result.action,
|
|
48
|
+
mcpToolName: toolName,
|
|
49
|
+
downstreamServer: process.env.BLEKLINE_MCP_PROXY_MOCK === "1" ? "mock" : "daytona",
|
|
50
|
+
clientSurface: ctx.clientSurface,
|
|
51
|
+
})
|
|
52
|
+
.catch(() => { });
|
|
53
|
+
if (result.action === "block") {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
action: "block",
|
|
57
|
+
message: "Blekline policy: block_and_review",
|
|
58
|
+
findings: result.findings,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (result.action === "mask") {
|
|
62
|
+
return {
|
|
63
|
+
ok: true,
|
|
64
|
+
action: "mask",
|
|
65
|
+
arguments: result.maskedArguments,
|
|
66
|
+
entitiesMasked: result.entitiesMasked,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, action: "allow", arguments: toolArgs };
|
|
70
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { enforceToolCallLocally } from "@blekline/contracts";
|
|
4
|
+
test("blocks destructive shell patterns", () => {
|
|
5
|
+
const result = enforceToolCallLocally({
|
|
6
|
+
toolName: "run_shell",
|
|
7
|
+
arguments: { command: "rm -rf /" },
|
|
8
|
+
requestId: "test-1",
|
|
9
|
+
});
|
|
10
|
+
assert.equal(result.action, "block");
|
|
11
|
+
});
|
|
12
|
+
test("masks AWS key in tool arguments", () => {
|
|
13
|
+
const result = enforceToolCallLocally({
|
|
14
|
+
toolName: "run_shell",
|
|
15
|
+
arguments: { command: "export AWS_KEY=AKIAIOSFODNN7EXAMPLE" },
|
|
16
|
+
requestId: "test-2",
|
|
17
|
+
});
|
|
18
|
+
assert.equal(result.action, "mask");
|
|
19
|
+
assert.ok(result.entitiesMasked > 0);
|
|
20
|
+
assert.ok(JSON.stringify(result.maskedArguments).includes("[AWS_KEY]"));
|
|
21
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blekline/mcp-proxy",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "AGPL-3.0-or-later",
|
|
5
|
+
"private": false,
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/Blekline/blekline-oss.git",
|
|
9
|
+
"directory": "packages/mcp-proxy"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"blekline-mcp-proxy": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
21
|
+
"@blekline/client": "0.1.0",
|
|
22
|
+
"@blekline/contracts": "0.1.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^20",
|
|
26
|
+
"typescript": "^5.7.3"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"start": "node dist/index.js",
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"test": "node --test dist/mcp-proxy.test.js"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4
|
+
|
|
5
|
+
export type DownstreamTool = {
|
|
6
|
+
name: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
inputSchema?: Record<string, unknown>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Mock downstream tools for demo without Daytona API key. */
|
|
12
|
+
export const MOCK_DOWNSTREAM_TOOLS: DownstreamTool[] = [
|
|
13
|
+
{
|
|
14
|
+
name: "run_shell",
|
|
15
|
+
description: "Run a shell command in sandbox (mock)",
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: { command: { type: "string" } },
|
|
19
|
+
required: ["command"],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "write_file",
|
|
24
|
+
description: "Write file in sandbox (mock)",
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: { path: { type: "string" }, content: { type: "string" } },
|
|
28
|
+
required: ["path", "content"],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export async function listDownstreamTools(): Promise<DownstreamTool[]> {
|
|
34
|
+
if (process.env.BLEKLINE_MCP_PROXY_MOCK === "1") {
|
|
35
|
+
return MOCK_DOWNSTREAM_TOOLS;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const cmd = process.env.BLEKLINE_DOWNSTREAM_MCP_COMMAND?.trim();
|
|
39
|
+
if (!cmd) return MOCK_DOWNSTREAM_TOOLS;
|
|
40
|
+
|
|
41
|
+
const parts = cmd.split(",").map((s) => s.trim()).filter(Boolean);
|
|
42
|
+
if (parts.length === 0) return MOCK_DOWNSTREAM_TOOLS;
|
|
43
|
+
|
|
44
|
+
const [command, ...args] = parts;
|
|
45
|
+
const transport = new StdioClientTransport({ command, args, env: process.env as Record<string, string> });
|
|
46
|
+
const client = new Client({ name: "blekline-proxy-downstream", version: "0.1.0" }, { capabilities: {} });
|
|
47
|
+
await client.connect(transport);
|
|
48
|
+
const listed = await client.listTools();
|
|
49
|
+
await client.close();
|
|
50
|
+
return listed.tools.map((t) => ({
|
|
51
|
+
name: t.name,
|
|
52
|
+
description: t.description,
|
|
53
|
+
inputSchema: t.inputSchema as Record<string, unknown>,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function callDownstreamTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
58
|
+
if (process.env.BLEKLINE_MCP_PROXY_MOCK === "1") {
|
|
59
|
+
return { ok: true, mock: true, tool: name, received: args };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const cmd = process.env.BLEKLINE_DOWNSTREAM_MCP_COMMAND?.trim();
|
|
63
|
+
if (!cmd) {
|
|
64
|
+
return { ok: true, mock: true, tool: name, received: args };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const parts = cmd.split(",").map((s) => s.trim()).filter(Boolean);
|
|
68
|
+
const [command, ...spawnArgs] = parts;
|
|
69
|
+
const transport = new StdioClientTransport({
|
|
70
|
+
command,
|
|
71
|
+
args: spawnArgs,
|
|
72
|
+
env: process.env as Record<string, string>,
|
|
73
|
+
});
|
|
74
|
+
const client = new Client({ name: "blekline-proxy-downstream", version: "0.1.0" }, { capabilities: {} });
|
|
75
|
+
await client.connect(transport);
|
|
76
|
+
const result = await client.callTool({ name, arguments: args });
|
|
77
|
+
await client.close();
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Spawn helper for health checks (unused in hot path). */
|
|
82
|
+
export function spawnDownstreamCheck(): void {
|
|
83
|
+
const cmd = process.env.BLEKLINE_DOWNSTREAM_MCP_COMMAND?.trim();
|
|
84
|
+
if (!cmd) return;
|
|
85
|
+
const parts = cmd.split(",").map((s) => s.trim()).filter(Boolean);
|
|
86
|
+
if (parts.length === 0) return;
|
|
87
|
+
spawn(parts[0], parts.slice(1), { stdio: "ignore" });
|
|
88
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { scanTextForSecrets } from "@blekline/contracts";
|
|
2
|
+
import type { ToolCallFinding } from "@blekline/contracts";
|
|
3
|
+
|
|
4
|
+
const DESTRUCTIVE_RE = /\brm\s+-rf\b|\bformat\s+c:\b|\bdrop\s+database\b/i;
|
|
5
|
+
|
|
6
|
+
export type ScanToolArgsResult = {
|
|
7
|
+
findings: ToolCallFinding[];
|
|
8
|
+
hasDestructive: boolean;
|
|
9
|
+
secretCount: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Fast local scan of MCP tool arguments (used before cloud enforce-tool-call). */
|
|
13
|
+
export function scanToolArgs(args: Record<string, unknown>): ScanToolArgsResult {
|
|
14
|
+
let blob = "";
|
|
15
|
+
try {
|
|
16
|
+
blob = JSON.stringify(args);
|
|
17
|
+
} catch {
|
|
18
|
+
blob = String(args);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const findings: ToolCallFinding[] = [];
|
|
22
|
+
if (DESTRUCTIVE_RE.test(blob)) {
|
|
23
|
+
findings.push({ id: "destructive_command", label: "DESTRUCTIVE", field: "arguments" });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (const s of scanTextForSecrets(blob)) {
|
|
27
|
+
findings.push({ id: s.id, label: s.label });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
findings,
|
|
32
|
+
hasDestructive: DESTRUCTIVE_RE.test(blob),
|
|
33
|
+
secretCount: findings.filter((f) => f.id !== "destructive_command").length,
|
|
34
|
+
};
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { callDownstreamTool, listDownstreamTools } from "./downstream/mcp-client.js";
|
|
5
|
+
import { createProxyContext, interceptToolCall } from "./mcp-proxy.js";
|
|
6
|
+
|
|
7
|
+
const server = new Server(
|
|
8
|
+
{ name: "blekline-mcp-proxy", version: "0.1.0" },
|
|
9
|
+
{ capabilities: { tools: {} } }
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
let ctx: ReturnType<typeof createProxyContext> | null = null;
|
|
13
|
+
|
|
14
|
+
function getCtx() {
|
|
15
|
+
if (!ctx) ctx = createProxyContext();
|
|
16
|
+
return ctx;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
20
|
+
const downstream = await listDownstreamTools();
|
|
21
|
+
return {
|
|
22
|
+
tools: downstream.map((t) => ({
|
|
23
|
+
name: t.name,
|
|
24
|
+
description: t.description ?? `Proxied tool (Blekline governed): ${t.name}`,
|
|
25
|
+
inputSchema: t.inputSchema ?? { type: "object", properties: {} },
|
|
26
|
+
})),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
31
|
+
const toolName = request.params.name;
|
|
32
|
+
const toolArgs = (request.params.arguments ?? {}) as Record<string, unknown>;
|
|
33
|
+
const enforcement = await interceptToolCall(getCtx(), toolName, toolArgs);
|
|
34
|
+
|
|
35
|
+
if (!enforcement.ok) {
|
|
36
|
+
return {
|
|
37
|
+
content: [
|
|
38
|
+
{
|
|
39
|
+
type: "text",
|
|
40
|
+
text: JSON.stringify(
|
|
41
|
+
{
|
|
42
|
+
error: enforcement.message,
|
|
43
|
+
action: enforcement.action,
|
|
44
|
+
findings: enforcement.findings,
|
|
45
|
+
},
|
|
46
|
+
null,
|
|
47
|
+
2
|
|
48
|
+
),
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
isError: true,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const downstreamResult = await callDownstreamTool(toolName, enforcement.arguments);
|
|
56
|
+
return {
|
|
57
|
+
content: [
|
|
58
|
+
{
|
|
59
|
+
type: "text",
|
|
60
|
+
text: JSON.stringify(
|
|
61
|
+
{
|
|
62
|
+
bleklineAction: enforcement.action,
|
|
63
|
+
entitiesMasked: enforcement.action === "mask" ? enforcement.entitiesMasked : 0,
|
|
64
|
+
result: downstreamResult,
|
|
65
|
+
},
|
|
66
|
+
null,
|
|
67
|
+
2
|
|
68
|
+
),
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
async function main() {
|
|
75
|
+
const transport = new StdioServerTransport();
|
|
76
|
+
await server.connect(transport);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
main().catch((err) => {
|
|
80
|
+
console.error("[blekline-mcp-proxy]", err);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { enforceToolCallLocally } from "@blekline/contracts";
|
|
4
|
+
|
|
5
|
+
test("blocks destructive shell patterns", () => {
|
|
6
|
+
const result = enforceToolCallLocally({
|
|
7
|
+
toolName: "run_shell",
|
|
8
|
+
arguments: { command: "rm -rf /" },
|
|
9
|
+
requestId: "test-1",
|
|
10
|
+
});
|
|
11
|
+
assert.equal(result.action, "block");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("masks AWS key in tool arguments", () => {
|
|
15
|
+
const result = enforceToolCallLocally({
|
|
16
|
+
toolName: "run_shell",
|
|
17
|
+
arguments: { command: "export AWS_KEY=AKIAIOSFODNN7EXAMPLE" },
|
|
18
|
+
requestId: "test-2",
|
|
19
|
+
});
|
|
20
|
+
assert.equal(result.action, "mask");
|
|
21
|
+
assert.ok(result.entitiesMasked > 0);
|
|
22
|
+
assert.ok(JSON.stringify(result.maskedArguments).includes("[AWS_KEY]"));
|
|
23
|
+
});
|
package/src/mcp-proxy.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { BleklineClient } from "@blekline/client";
|
|
3
|
+
import { enforceToolCallLocally, type ClientSurface } from "@blekline/contracts";
|
|
4
|
+
|
|
5
|
+
export type ProxyEnforcementContext = {
|
|
6
|
+
client: BleklineClient;
|
|
7
|
+
clientSurface: ClientSurface;
|
|
8
|
+
useCloudEnforcement: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type InterceptResult =
|
|
12
|
+
| { ok: true; action: "allow"; arguments: Record<string, unknown> }
|
|
13
|
+
| { ok: true; action: "mask"; arguments: Record<string, unknown>; entitiesMasked: number }
|
|
14
|
+
| { ok: false; action: "block"; message: string; findings: unknown[] };
|
|
15
|
+
|
|
16
|
+
function envClientSurface(): ClientSurface {
|
|
17
|
+
const v = process.env.BLEKLINE_CLIENT_SURFACE?.trim();
|
|
18
|
+
if (v === "cursor" || v === "claude-desktop" || v === "codex") return v;
|
|
19
|
+
return "sdk";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createProxyContext(): ProxyEnforcementContext {
|
|
23
|
+
const token = process.env.BLEKLINE_WORKSPACE_TOKEN?.trim();
|
|
24
|
+
if (!token) throw new Error("BLEKLINE_WORKSPACE_TOKEN is required");
|
|
25
|
+
return {
|
|
26
|
+
client: new BleklineClient({
|
|
27
|
+
baseUrl: process.env.BLEKLINE_API_URL?.trim(),
|
|
28
|
+
workspaceToken: token,
|
|
29
|
+
workspaceId: process.env.BLEKLINE_WORKSPACE_ID?.trim(),
|
|
30
|
+
metadata: { clientSurface: envClientSurface() },
|
|
31
|
+
}),
|
|
32
|
+
clientSurface: envClientSurface(),
|
|
33
|
+
useCloudEnforcement: process.env.BLEKLINE_PROXY_LOCAL_ONLY !== "1",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function interceptToolCall(
|
|
38
|
+
ctx: ProxyEnforcementContext,
|
|
39
|
+
toolName: string,
|
|
40
|
+
toolArgs: Record<string, unknown>
|
|
41
|
+
): Promise<InterceptResult> {
|
|
42
|
+
const requestId = randomUUID();
|
|
43
|
+
let result = enforceToolCallLocally({ toolName, arguments: toolArgs, requestId });
|
|
44
|
+
|
|
45
|
+
if (ctx.useCloudEnforcement) {
|
|
46
|
+
try {
|
|
47
|
+
result = await ctx.client.enforceToolCall({
|
|
48
|
+
toolName,
|
|
49
|
+
arguments: toolArgs,
|
|
50
|
+
platform: "MCP-Proxy",
|
|
51
|
+
clientSurface: ctx.clientSurface,
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
/* fall back to local result */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
void ctx.client
|
|
59
|
+
.emitEvent({
|
|
60
|
+
kind: "tool_call_enforcement",
|
|
61
|
+
platform: "MCP-Proxy",
|
|
62
|
+
entitiesMasked: result.entitiesMasked,
|
|
63
|
+
riskTier: result.riskTier,
|
|
64
|
+
action: result.action,
|
|
65
|
+
mcpToolName: toolName,
|
|
66
|
+
downstreamServer: process.env.BLEKLINE_MCP_PROXY_MOCK === "1" ? "mock" : "daytona",
|
|
67
|
+
clientSurface: ctx.clientSurface,
|
|
68
|
+
})
|
|
69
|
+
.catch(() => {});
|
|
70
|
+
|
|
71
|
+
if (result.action === "block") {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
action: "block",
|
|
75
|
+
message: "Blekline policy: block_and_review",
|
|
76
|
+
findings: result.findings,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (result.action === "mask") {
|
|
81
|
+
return {
|
|
82
|
+
ok: true,
|
|
83
|
+
action: "mask",
|
|
84
|
+
arguments: result.maskedArguments,
|
|
85
|
+
entitiesMasked: result.entitiesMasked,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { ok: true, action: "allow", arguments: toolArgs };
|
|
90
|
+
}
|