@clawdreyhepburn/carapace 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 +190 -0
- package/NOTICE +13 -0
- package/README.md +408 -0
- package/openclaw.plugin.json +57 -0
- package/package.json +32 -0
- package/src/cedar-engine-cedarling.ts +585 -0
- package/src/cedar-engine.ts +426 -0
- package/src/gui/html.ts +919 -0
- package/src/gui/server.ts +169 -0
- package/src/index.ts +208 -0
- package/src/mcp-aggregator.ts +178 -0
- package/src/types.ts +107 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control GUI — lightweight local web server for human oversight of MCP tool access.
|
|
3
|
+
*
|
|
4
|
+
* Serves a single-page app that shows all MCP servers and tools,
|
|
5
|
+
* lets humans toggle access, and displays Cedar policy state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createServer, type IncomingMessage, type ServerResponse, type Server } from "node:http";
|
|
9
|
+
import type { McpAggregator } from "../mcp-aggregator.js";
|
|
10
|
+
import type { Logger, CedarEngineInterface } from "../types.js";
|
|
11
|
+
import { guiHtml } from "./html.js";
|
|
12
|
+
|
|
13
|
+
interface GuiOpts {
|
|
14
|
+
port: number;
|
|
15
|
+
aggregator: McpAggregator;
|
|
16
|
+
cedar: CedarEngineInterface;
|
|
17
|
+
logger: Logger;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class ControlGui {
|
|
21
|
+
private port: number;
|
|
22
|
+
private aggregator: McpAggregator;
|
|
23
|
+
private cedar: CedarEngineInterface;
|
|
24
|
+
private logger: Logger;
|
|
25
|
+
private server: Server | null = null;
|
|
26
|
+
|
|
27
|
+
constructor(opts: GuiOpts) {
|
|
28
|
+
this.port = opts.port;
|
|
29
|
+
this.aggregator = opts.aggregator;
|
|
30
|
+
this.cedar = opts.cedar;
|
|
31
|
+
this.logger = opts.logger;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async start(): Promise<void> {
|
|
35
|
+
this.server = createServer((req, res) => this.handleRequest(req, res));
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
this.server!.listen(this.port, "127.0.0.1", () => {
|
|
38
|
+
this.logger.info(`GUI listening on http://127.0.0.1:${this.port}`);
|
|
39
|
+
resolve();
|
|
40
|
+
});
|
|
41
|
+
this.server!.on("error", reject);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async stop(): Promise<void> {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
if (this.server) {
|
|
48
|
+
this.server.close(() => resolve());
|
|
49
|
+
} else {
|
|
50
|
+
resolve();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
56
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
// --- API routes ---
|
|
60
|
+
if (url.pathname === "/api/status" && req.method === "GET") {
|
|
61
|
+
const servers = this.aggregator.getServerStatus();
|
|
62
|
+
const tools = this.aggregator.listTools();
|
|
63
|
+
this.json(res, {
|
|
64
|
+
servers,
|
|
65
|
+
tools,
|
|
66
|
+
policies: this.cedar.getPolicies(),
|
|
67
|
+
toolCount: tools.length,
|
|
68
|
+
enabledCount: tools.filter((t) => t.enabled).length,
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (url.pathname === "/api/tools" && req.method === "GET") {
|
|
74
|
+
const server = url.searchParams.get("server") ?? undefined;
|
|
75
|
+
this.json(res, this.aggregator.listTools(server));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (url.pathname === "/api/toggle" && req.method === "POST") {
|
|
80
|
+
const body = await this.readBody(req);
|
|
81
|
+
const { tool, enabled } = JSON.parse(body);
|
|
82
|
+
if (enabled) {
|
|
83
|
+
this.cedar.enableTool(tool);
|
|
84
|
+
} else {
|
|
85
|
+
this.cedar.disableTool(tool);
|
|
86
|
+
}
|
|
87
|
+
this.json(res, { ok: true, tool, enabled });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (url.pathname === "/api/verify" && req.method === "POST") {
|
|
92
|
+
const result = await this.cedar.verify();
|
|
93
|
+
this.json(res, result);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (url.pathname === "/api/policies" && req.method === "GET") {
|
|
98
|
+
this.json(res, this.cedar.getPolicies());
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (url.pathname === "/api/policy" && req.method === "POST") {
|
|
103
|
+
const body = await this.readBody(req);
|
|
104
|
+
const { id, raw } = JSON.parse(body);
|
|
105
|
+
if (!id || !raw) {
|
|
106
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
107
|
+
res.end(JSON.stringify({ error: "id and raw are required" }));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.cedar.savePolicy(id, raw);
|
|
111
|
+
this.json(res, { ok: true, id });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (url.pathname === "/api/policy" && req.method === "DELETE") {
|
|
116
|
+
const body = await this.readBody(req);
|
|
117
|
+
const { id } = JSON.parse(body);
|
|
118
|
+
const deleted = this.cedar.deletePolicy(id);
|
|
119
|
+
this.json(res, { ok: deleted, id });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (url.pathname === "/api/schema" && req.method === "GET") {
|
|
124
|
+
this.json(res, this.cedar.getSchema());
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (url.pathname === "/api/schema" && req.method === "POST") {
|
|
129
|
+
const body = await this.readBody(req);
|
|
130
|
+
const { raw } = JSON.parse(body);
|
|
131
|
+
this.cedar.saveSchema(raw);
|
|
132
|
+
this.json(res, { ok: true });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --- GUI ---
|
|
137
|
+
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
138
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
139
|
+
res.end(guiHtml());
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 404
|
|
144
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
145
|
+
res.end("Not Found");
|
|
146
|
+
} catch (err: any) {
|
|
147
|
+
this.logger.error(`GUI error: ${err.message}`);
|
|
148
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
149
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private json(res: ServerResponse, data: any): void {
|
|
154
|
+
res.writeHead(200, {
|
|
155
|
+
"Content-Type": "application/json",
|
|
156
|
+
"Access-Control-Allow-Origin": "*",
|
|
157
|
+
});
|
|
158
|
+
res.end(JSON.stringify(data));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private readBody(req: IncomingMessage): Promise<string> {
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
const chunks: Buffer[] = [];
|
|
164
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
165
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
166
|
+
req.on("error", reject);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carapace — OpenClaw Plugin
|
|
3
|
+
*
|
|
4
|
+
* Aggregates upstream MCP servers, enforces Cedar policies on tool access,
|
|
5
|
+
* and serves a local GUI for human oversight.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { CedarlingEngine } from "./cedar-engine-cedarling.js";
|
|
9
|
+
import { McpAggregator } from "./mcp-aggregator.js";
|
|
10
|
+
import { ControlGui } from "./gui/server.js";
|
|
11
|
+
import type { PluginConfig } from "./types.js";
|
|
12
|
+
|
|
13
|
+
export const id = "carapace";
|
|
14
|
+
export const name = "Carapace";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* OpenClaw plugin API shape (matches real runtime).
|
|
18
|
+
* We define it here to avoid depending on OpenClaw types at build time.
|
|
19
|
+
*/
|
|
20
|
+
interface OpenClawPluginApi {
|
|
21
|
+
pluginConfig: any;
|
|
22
|
+
logger: {
|
|
23
|
+
info(msg: string, ...args: any[]): void;
|
|
24
|
+
warn(msg: string, ...args: any[]): void;
|
|
25
|
+
error(msg: string, ...args: any[]): void;
|
|
26
|
+
debug?(msg: string, ...args: any[]): void;
|
|
27
|
+
};
|
|
28
|
+
registerService(service: { id: string; start(): Promise<void> | void; stop(): Promise<void> | void }): void;
|
|
29
|
+
registerTool(
|
|
30
|
+
tool: {
|
|
31
|
+
name: string;
|
|
32
|
+
label?: string;
|
|
33
|
+
description: string;
|
|
34
|
+
parameters: Record<string, any>;
|
|
35
|
+
execute(toolCallId: string, params: any): Promise<any>;
|
|
36
|
+
},
|
|
37
|
+
opts?: { optional?: boolean },
|
|
38
|
+
): void;
|
|
39
|
+
registerCli?(fn: (ctx: { program: any }) => void, opts?: { commands: string[] }): void;
|
|
40
|
+
registerGatewayMethod?(name: string, handler: (ctx: { respond: (ok: boolean, data: any) => void }) => void): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function register(api: OpenClawPluginApi) {
|
|
44
|
+
const config: PluginConfig = api.pluginConfig ?? {};
|
|
45
|
+
const logger = api.logger;
|
|
46
|
+
|
|
47
|
+
const cedar = new CedarlingEngine({
|
|
48
|
+
policyDir: config.policyDir ?? "~/.openclaw/mcp-policies/",
|
|
49
|
+
defaultPolicy: config.defaultPolicy ?? "allow-all",
|
|
50
|
+
verify: config.verify ?? false,
|
|
51
|
+
logger,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const aggregator = new McpAggregator({
|
|
55
|
+
servers: config.servers ?? {},
|
|
56
|
+
cedar,
|
|
57
|
+
logger,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const gui = new ControlGui({
|
|
61
|
+
port: config.guiPort ?? 19820,
|
|
62
|
+
aggregator,
|
|
63
|
+
cedar,
|
|
64
|
+
logger,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// --- Background service: connect to MCP servers and serve GUI ---
|
|
68
|
+
api.registerService({
|
|
69
|
+
id: "carapace",
|
|
70
|
+
async start() {
|
|
71
|
+
logger.info("Carapace starting...");
|
|
72
|
+
await cedar.init();
|
|
73
|
+
await aggregator.connectAll();
|
|
74
|
+
await gui.start();
|
|
75
|
+
logger.info(`Control GUI at http://localhost:${config.guiPort ?? 19820}`);
|
|
76
|
+
},
|
|
77
|
+
async stop() {
|
|
78
|
+
await gui.stop();
|
|
79
|
+
await aggregator.disconnectAll();
|
|
80
|
+
logger.info("Carapace stopped");
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// --- Agent tool: list available MCP tools ---
|
|
85
|
+
api.registerTool({
|
|
86
|
+
name: "mcp_tools",
|
|
87
|
+
label: "MCP Tools (Carapace)",
|
|
88
|
+
description:
|
|
89
|
+
"List all MCP tools available through the Carapace Cedar proxy, with their enabled/disabled status",
|
|
90
|
+
parameters: {
|
|
91
|
+
type: "object",
|
|
92
|
+
properties: {
|
|
93
|
+
server: {
|
|
94
|
+
type: "string",
|
|
95
|
+
description: "Filter by server name (optional)",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
async execute(_toolCallId: string, params: { server?: string }) {
|
|
100
|
+
const tools = aggregator.listTools(params.server);
|
|
101
|
+
return {
|
|
102
|
+
content: [
|
|
103
|
+
{
|
|
104
|
+
type: "text",
|
|
105
|
+
text: JSON.stringify(tools, null, 2),
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// --- Agent tool: invoke an MCP tool through the proxy ---
|
|
113
|
+
api.registerTool({
|
|
114
|
+
name: "mcp_call",
|
|
115
|
+
label: "MCP Call (Carapace)",
|
|
116
|
+
description:
|
|
117
|
+
"Call an MCP tool through the Carapace Cedar proxy. The call is authorized by Cedar policies before reaching the upstream server.",
|
|
118
|
+
parameters: {
|
|
119
|
+
type: "object",
|
|
120
|
+
required: ["tool"],
|
|
121
|
+
properties: {
|
|
122
|
+
tool: {
|
|
123
|
+
type: "string",
|
|
124
|
+
description: 'Fully qualified tool name (e.g., "github/create_issue")',
|
|
125
|
+
},
|
|
126
|
+
arguments: {
|
|
127
|
+
type: "object",
|
|
128
|
+
description: "Arguments to pass to the tool",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
async execute(_toolCallId: string, params: { tool: string; arguments?: Record<string, unknown> }) {
|
|
133
|
+
const { tool, arguments: args } = params;
|
|
134
|
+
|
|
135
|
+
// Authorize via Cedar
|
|
136
|
+
const decision = await cedar.authorize({
|
|
137
|
+
principal: 'Agent::"openclaw"',
|
|
138
|
+
action: 'Action::"call_tool"',
|
|
139
|
+
resource: `Tool::"${tool}"`,
|
|
140
|
+
context: args ? { arguments: args } : {},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (decision.decision === "deny") {
|
|
144
|
+
return {
|
|
145
|
+
content: [
|
|
146
|
+
{
|
|
147
|
+
type: "text",
|
|
148
|
+
text: `DENIED by Cedar policy: ${tool}\nReason: ${decision.reasons.join(", ") || "default deny"}`,
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
isError: true,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Forward to upstream MCP server
|
|
156
|
+
const result = await aggregator.callTool(tool, args ?? {});
|
|
157
|
+
return result;
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// --- CLI command ---
|
|
162
|
+
api.registerCli?.(
|
|
163
|
+
({ program }) => {
|
|
164
|
+
const cmd = program.command("carapace").description("Carapace — MCP tool authorization");
|
|
165
|
+
|
|
166
|
+
cmd.command("status").action(async () => {
|
|
167
|
+
const servers = aggregator.getServerStatus();
|
|
168
|
+
console.log("\n🦞 Carapace Status\n");
|
|
169
|
+
for (const [name, status] of Object.entries(servers)) {
|
|
170
|
+
const icon = status.connected ? "✅" : "❌";
|
|
171
|
+
console.log(` ${icon} ${name} — ${status.toolCount} tools`);
|
|
172
|
+
}
|
|
173
|
+
const tools = aggregator.listTools();
|
|
174
|
+
const enabled = tools.filter((t) => t.enabled).length;
|
|
175
|
+
console.log(`\n ${enabled}/${tools.length} tools enabled`);
|
|
176
|
+
console.log(` GUI: http://localhost:${config.guiPort ?? 19820}\n`);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
cmd.command("tools").action(async () => {
|
|
180
|
+
const tools = aggregator.listTools();
|
|
181
|
+
for (const tool of tools) {
|
|
182
|
+
const icon = tool.enabled ? "🟢" : "🔴";
|
|
183
|
+
console.log(` ${icon} ${tool.qualifiedName} — ${tool.description}`);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
cmd.command("verify").action(async () => {
|
|
188
|
+
const result = await cedar.verify();
|
|
189
|
+
if (result.ok) {
|
|
190
|
+
console.log("✅ All policies verified");
|
|
191
|
+
} else {
|
|
192
|
+
console.log("⚠️ Verification issues:");
|
|
193
|
+
for (const issue of result.issues) {
|
|
194
|
+
console.log(` - ${issue}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
{ commands: ["carapace"] },
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// --- Gateway RPC ---
|
|
203
|
+
api.registerGatewayMethod?.("carapace.status", ({ respond }) => {
|
|
204
|
+
const servers = aggregator.getServerStatus();
|
|
205
|
+
const tools = aggregator.listTools();
|
|
206
|
+
respond(true, { servers, toolCount: tools.length, enabledCount: tools.filter((t) => t.enabled).length });
|
|
207
|
+
});
|
|
208
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Aggregator — connects to multiple upstream MCP servers,
|
|
3
|
+
* discovers their tools, and proxies calls through Cedar authorization.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
7
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
8
|
+
// import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
9
|
+
// import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
10
|
+
import type { Logger, ServerConfig, McpTool, ServerStatus, CedarEngineInterface } from "./types.js";
|
|
11
|
+
|
|
12
|
+
interface AggregatorOpts {
|
|
13
|
+
servers: Record<string, ServerConfig>;
|
|
14
|
+
cedar: CedarEngineInterface;
|
|
15
|
+
logger: Logger;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ConnectedServer {
|
|
19
|
+
name: string;
|
|
20
|
+
config: ServerConfig;
|
|
21
|
+
client: Client;
|
|
22
|
+
transport: any;
|
|
23
|
+
tools: McpTool[];
|
|
24
|
+
connected: boolean;
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class McpAggregator {
|
|
29
|
+
private servers: Map<string, ConnectedServer> = new Map();
|
|
30
|
+
private serverConfigs: Record<string, ServerConfig>;
|
|
31
|
+
private cedar: CedarEngineInterface;
|
|
32
|
+
private logger: Logger;
|
|
33
|
+
|
|
34
|
+
constructor(opts: AggregatorOpts) {
|
|
35
|
+
this.serverConfigs = opts.servers;
|
|
36
|
+
this.cedar = opts.cedar;
|
|
37
|
+
this.logger = opts.logger;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Connect to all configured MCP servers and discover tools */
|
|
41
|
+
async connectAll(): Promise<void> {
|
|
42
|
+
const entries = Object.entries(this.serverConfigs);
|
|
43
|
+
this.logger.info(`Connecting to ${entries.length} MCP server(s)...`);
|
|
44
|
+
|
|
45
|
+
await Promise.allSettled(
|
|
46
|
+
entries.map(([name, config]) => this.connectServer(name, config)),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const total = this.listTools().length;
|
|
50
|
+
const connected = [...this.servers.values()].filter((s) => s.connected).length;
|
|
51
|
+
this.logger.info(`Connected: ${connected}/${entries.length} servers, ${total} tools discovered`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Disconnect all servers */
|
|
55
|
+
async disconnectAll(): Promise<void> {
|
|
56
|
+
for (const [name, server] of this.servers) {
|
|
57
|
+
try {
|
|
58
|
+
await server.transport?.close?.();
|
|
59
|
+
this.logger.debug?.(`Disconnected: ${name}`);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
this.logger.warn(`Error disconnecting ${name}: ${err}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
this.servers.clear();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** List all discovered tools across all servers */
|
|
68
|
+
listTools(serverFilter?: string): McpTool[] {
|
|
69
|
+
const tools: McpTool[] = [];
|
|
70
|
+
for (const server of this.servers.values()) {
|
|
71
|
+
if (serverFilter && server.name !== serverFilter) continue;
|
|
72
|
+
for (const tool of server.tools) {
|
|
73
|
+
tools.push({
|
|
74
|
+
...tool,
|
|
75
|
+
enabled: this.cedar.isToolEnabled(tool.qualifiedName),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return tools;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Get status of all servers */
|
|
83
|
+
getServerStatus(): Record<string, ServerStatus> {
|
|
84
|
+
const status: Record<string, ServerStatus> = {};
|
|
85
|
+
for (const [name, server] of this.servers) {
|
|
86
|
+
status[name] = {
|
|
87
|
+
connected: server.connected,
|
|
88
|
+
toolCount: server.tools.length,
|
|
89
|
+
error: server.error,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return status;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Call a tool on the appropriate upstream server */
|
|
96
|
+
async callTool(qualifiedName: string, args: Record<string, unknown>): Promise<any> {
|
|
97
|
+
const [serverName, toolName] = qualifiedName.split("/", 2);
|
|
98
|
+
const server = this.servers.get(serverName);
|
|
99
|
+
|
|
100
|
+
if (!server) {
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: "text", text: `Server not found: ${serverName}` }],
|
|
103
|
+
isError: true,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!server.connected) {
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: "text", text: `Server not connected: ${serverName}` }],
|
|
110
|
+
isError: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const result = await server.client.callTool({ name: toolName, arguments: args });
|
|
116
|
+
return result;
|
|
117
|
+
} catch (err: any) {
|
|
118
|
+
return {
|
|
119
|
+
content: [{ type: "text", text: `Tool call failed: ${err.message}` }],
|
|
120
|
+
isError: true,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- Private ---
|
|
126
|
+
|
|
127
|
+
private async connectServer(name: string, config: ServerConfig): Promise<void> {
|
|
128
|
+
const entry: ConnectedServer = {
|
|
129
|
+
name,
|
|
130
|
+
config,
|
|
131
|
+
client: new Client({ name: `mcp-cedar-proxy/${name}`, version: "0.1.0" }),
|
|
132
|
+
transport: null,
|
|
133
|
+
tools: [],
|
|
134
|
+
connected: false,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
if (config.transport === "stdio") {
|
|
139
|
+
if (!config.command) throw new Error("stdio transport requires 'command'");
|
|
140
|
+
|
|
141
|
+
entry.transport = new StdioClientTransport({
|
|
142
|
+
command: config.command,
|
|
143
|
+
args: config.args ?? [],
|
|
144
|
+
env: { ...process.env, ...(config.env ?? {}) } as Record<string, string>,
|
|
145
|
+
});
|
|
146
|
+
} else if (config.transport === "http" || config.transport === "sse") {
|
|
147
|
+
if (!config.url) throw new Error(`${config.transport} transport requires 'url'`);
|
|
148
|
+
|
|
149
|
+
// TODO: Add HTTP/SSE transport support
|
|
150
|
+
// For now, only stdio is implemented
|
|
151
|
+
throw new Error(`${config.transport} transport not yet implemented — use stdio`);
|
|
152
|
+
} else {
|
|
153
|
+
throw new Error(`Unknown transport: ${config.transport}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await entry.client.connect(entry.transport);
|
|
157
|
+
entry.connected = true;
|
|
158
|
+
|
|
159
|
+
// Discover tools
|
|
160
|
+
const toolsResult = await entry.client.listTools();
|
|
161
|
+
entry.tools = (toolsResult.tools ?? []).map((t) => ({
|
|
162
|
+
name: t.name,
|
|
163
|
+
qualifiedName: `${name}/${t.name}`,
|
|
164
|
+
server: name,
|
|
165
|
+
description: t.description ?? "",
|
|
166
|
+
inputSchema: t.inputSchema,
|
|
167
|
+
enabled: false, // Will be resolved against Cedar policies
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
this.logger.info(`Connected to ${name}: ${entry.tools.length} tools`);
|
|
171
|
+
} catch (err: any) {
|
|
172
|
+
entry.error = err.message;
|
|
173
|
+
this.logger.warn(`Failed to connect to ${name}: ${err.message}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.servers.set(name, entry);
|
|
177
|
+
}
|
|
178
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for the MCP Cedar Proxy plugin.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export interface Logger {
|
|
7
|
+
info(msg: string, ...args: any[]): void;
|
|
8
|
+
warn(msg: string, ...args: any[]): void;
|
|
9
|
+
error(msg: string, ...args: any[]): void;
|
|
10
|
+
debug?(msg: string, ...args: any[]): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ToolDef {
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
parameters: Record<string, any>;
|
|
17
|
+
handler(args: any): Promise<any>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PluginConfig {
|
|
21
|
+
guiPort?: number;
|
|
22
|
+
servers?: Record<string, ServerConfig>;
|
|
23
|
+
policyDir?: string;
|
|
24
|
+
defaultPolicy?: "deny-all" | "allow-all";
|
|
25
|
+
verify?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ServerConfig {
|
|
29
|
+
transport: "stdio" | "http" | "sse";
|
|
30
|
+
command?: string;
|
|
31
|
+
args?: string[];
|
|
32
|
+
env?: Record<string, string>;
|
|
33
|
+
url?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface McpTool {
|
|
37
|
+
name: string;
|
|
38
|
+
qualifiedName: string; // "server/tool"
|
|
39
|
+
server: string;
|
|
40
|
+
description: string;
|
|
41
|
+
inputSchema?: Record<string, any>;
|
|
42
|
+
enabled: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ServerStatus {
|
|
46
|
+
connected: boolean;
|
|
47
|
+
toolCount: number;
|
|
48
|
+
lastSeen?: number;
|
|
49
|
+
error?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CedarDecision {
|
|
53
|
+
decision: "allow" | "deny";
|
|
54
|
+
reasons: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AuthzRequest {
|
|
58
|
+
principal: string;
|
|
59
|
+
action: string;
|
|
60
|
+
resource: string;
|
|
61
|
+
context?: Record<string, unknown>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Common interface for Cedar engines (homebrew and Cedarling) */
|
|
65
|
+
export interface CedarEngineInterface {
|
|
66
|
+
init(): Promise<void>;
|
|
67
|
+
authorize(request: AuthzRequest): Promise<CedarDecision>;
|
|
68
|
+
enableTool(qualifiedName: string): void;
|
|
69
|
+
disableTool(qualifiedName: string): void;
|
|
70
|
+
isToolEnabled(qualifiedName: string): boolean;
|
|
71
|
+
savePolicy(id: string, raw: string): void;
|
|
72
|
+
deletePolicy(id: string): boolean;
|
|
73
|
+
getPolicies(): Array<{ id: string; effect: string; raw: string }>;
|
|
74
|
+
getSchema(): CedarSchemaInfo;
|
|
75
|
+
saveSchema(raw: string): void;
|
|
76
|
+
verify(): Promise<VerifyResult>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface VerifyResult {
|
|
80
|
+
ok: boolean;
|
|
81
|
+
issues: string[];
|
|
82
|
+
durationMs: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface CedarSchemaInfo {
|
|
86
|
+
entities: SchemaEntity[];
|
|
87
|
+
actions: SchemaAction[];
|
|
88
|
+
raw: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SchemaEntity {
|
|
92
|
+
name: string;
|
|
93
|
+
parents: string[];
|
|
94
|
+
attributes: SchemaAttribute[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface SchemaAttribute {
|
|
98
|
+
name: string;
|
|
99
|
+
type: string;
|
|
100
|
+
optional: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface SchemaAction {
|
|
104
|
+
name: string;
|
|
105
|
+
principalTypes: string[];
|
|
106
|
+
resourceTypes: string[];
|
|
107
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"sourceMap": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules", "dist", "test"]
|
|
16
|
+
}
|