multicorn-shield 0.2.0 → 0.2.2
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/README.md +22 -0
- package/dist/multicorn-proxy.js +34 -17
- package/dist/multicorn-shield.js +118 -0
- package/dist/openclaw-hook/handler.js +2 -2
- package/dist/openclaw-plugin/multicorn-shield.js +2 -2
- package/dist/proxy.cjs +457 -0
- package/dist/proxy.d.cts +235 -0
- package/dist/proxy.d.ts +235 -0
- package/dist/proxy.js +438 -0
- package/dist/shield-extension.js +23117 -0
- package/package.json +51 -21
package/README.md
CHANGED
|
@@ -54,6 +54,28 @@ That's it. Every tool call now goes through Shield's permission layer, and activ
|
|
|
54
54
|
|
|
55
55
|
See the [full MCP proxy guide](https://multicorn.ai/docs/mcp-proxy) for Claude Code, OpenClaw, and generic MCP client examples.
|
|
56
56
|
|
|
57
|
+
### Claude Desktop Extension (.mcpb)
|
|
58
|
+
|
|
59
|
+
Install Shield without the terminal: download the `.mcpb` bundle (or use **Install** from the Shield product page), open it in Claude Desktop, and enter your API key when prompted. The extension reads your existing MCP servers from `claude_desktop_config.json`, runs them as child processes, merges their tools, and checks every `tools/call` with the Shield API. Activity still shows up in your [Multicorn dashboard](https://app.multicorn.ai).
|
|
60
|
+
|
|
61
|
+
**Disable or uninstall recovery:** On each start the extension saves a copy of your `mcpServers` block to `~/.multicorn/extension-backup.json` with restricted file permissions (owner read/write only). If you turn the extension off and need your original Claude Desktop MCP entries back, run:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npx multicorn-shield restore
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then restart Claude Desktop. That overwrites `mcpServers` in your config with the last backup.
|
|
68
|
+
|
|
69
|
+
**Duplicate tool names:** If two MCP servers expose the same tool name, the first server in your config file keeps the name. The duplicate is skipped and a warning is written to the extension logs (stderr). Rename tools on the server side if you need both.
|
|
70
|
+
|
|
71
|
+
Build the bundle locally (requires a full `pnpm build` first):
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pnpm run pack:extension
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This runs `mcpb validate` and writes `dist/multicorn-shield.mcpb`.
|
|
78
|
+
|
|
57
79
|
### Option 2: OpenClaw Plugin (native integration)
|
|
58
80
|
|
|
59
81
|
If you're running [OpenClaw](https://openclaw.ai), Shield integrates directly as a plugin. No proxy layer, no code changes. The plugin intercepts every tool call at the infrastructure level before it executes.
|
package/dist/multicorn-proxy.js
CHANGED
|
@@ -297,7 +297,7 @@ async function isClaudeDesktopConnected() {
|
|
|
297
297
|
return false;
|
|
298
298
|
}
|
|
299
299
|
}
|
|
300
|
-
async function runInit(baseUrl = "https://api.multicorn.ai") {
|
|
300
|
+
async function runInit(baseUrl = "https://api.multicorn.ai", platform) {
|
|
301
301
|
if (!process.stdin.isTTY) {
|
|
302
302
|
process.stderr.write(
|
|
303
303
|
style.red("Error: interactive terminal required. Cannot run init with piped input.") + "\n"
|
|
@@ -320,15 +320,22 @@ async function runInit(baseUrl = "https://api.multicorn.ai") {
|
|
|
320
320
|
);
|
|
321
321
|
let apiKey = "";
|
|
322
322
|
const existing = await loadConfig().catch(() => null);
|
|
323
|
+
if (baseUrl === "https://api.multicorn.ai") {
|
|
324
|
+
if (existing !== null && existing.baseUrl.length > 0) {
|
|
325
|
+
baseUrl = existing.baseUrl;
|
|
326
|
+
} else {
|
|
327
|
+
const envBaseUrl = process.env["MULTICORN_BASE_URL"];
|
|
328
|
+
if (envBaseUrl !== void 0 && envBaseUrl.length > 0) {
|
|
329
|
+
baseUrl = envBaseUrl;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
323
333
|
if (existing !== null && existing.apiKey.startsWith("mcs_") && existing.apiKey.length >= 8) {
|
|
324
334
|
const masked = "mcs_..." + existing.apiKey.slice(-4);
|
|
325
335
|
process.stderr.write("Found existing API key: " + style.cyan(masked) + "\n");
|
|
326
336
|
const answer = await ask("Use this key? (Y/n) ");
|
|
327
337
|
if (answer.trim().toLowerCase() !== "n") {
|
|
328
338
|
apiKey = existing.apiKey;
|
|
329
|
-
if (baseUrl === "https://api.multicorn.ai") {
|
|
330
|
-
baseUrl = existing.baseUrl;
|
|
331
|
-
}
|
|
332
339
|
}
|
|
333
340
|
}
|
|
334
341
|
while (apiKey.length === 0) {
|
|
@@ -354,7 +361,11 @@ async function runInit(baseUrl = "https://api.multicorn.ai") {
|
|
|
354
361
|
apiKey = key;
|
|
355
362
|
}
|
|
356
363
|
const configuredPlatforms = /* @__PURE__ */ new Set();
|
|
357
|
-
let lastConfig = {
|
|
364
|
+
let lastConfig = {
|
|
365
|
+
apiKey,
|
|
366
|
+
baseUrl,
|
|
367
|
+
...{}
|
|
368
|
+
};
|
|
358
369
|
let configuring = true;
|
|
359
370
|
while (configuring) {
|
|
360
371
|
process.stderr.write(
|
|
@@ -589,7 +600,7 @@ async function runInit(baseUrl = "https://api.multicorn.ai") {
|
|
|
589
600
|
);
|
|
590
601
|
}
|
|
591
602
|
configuredPlatforms.add(selection);
|
|
592
|
-
lastConfig = { apiKey, baseUrl, agentName };
|
|
603
|
+
lastConfig = { apiKey, baseUrl, agentName, ...{} };
|
|
593
604
|
try {
|
|
594
605
|
await saveConfig(lastConfig);
|
|
595
606
|
process.stderr.write(style.green("\u2713") + ` Config saved to ${style.cyan(CONFIG_PATH)}
|
|
@@ -1237,14 +1248,14 @@ async function findAgentByName(agentName, apiKey, baseUrl) {
|
|
|
1237
1248
|
if (match === void 0) return null;
|
|
1238
1249
|
return { id: match.id, name: match.name, scopes: [] };
|
|
1239
1250
|
}
|
|
1240
|
-
async function registerAgent(agentName, apiKey, baseUrl) {
|
|
1251
|
+
async function registerAgent(agentName, apiKey, baseUrl, platform) {
|
|
1241
1252
|
const response = await fetch(`${baseUrl}/api/v1/agents`, {
|
|
1242
1253
|
method: "POST",
|
|
1243
1254
|
headers: {
|
|
1244
1255
|
"Content-Type": "application/json",
|
|
1245
1256
|
"X-Multicorn-Key": apiKey
|
|
1246
1257
|
},
|
|
1247
|
-
body: JSON.stringify({ name: agentName }),
|
|
1258
|
+
body: JSON.stringify({ name: agentName, ...platform ? { platform } : {} }),
|
|
1248
1259
|
signal: AbortSignal.timeout(8e3)
|
|
1249
1260
|
});
|
|
1250
1261
|
if (!response.ok) {
|
|
@@ -1299,9 +1310,9 @@ function openBrowser(url) {
|
|
|
1299
1310
|
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
1300
1311
|
spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
|
|
1301
1312
|
}
|
|
1302
|
-
async function waitForConsent(agentId, agentName, apiKey, baseUrl, dashboardUrl, logger, scope) {
|
|
1313
|
+
async function waitForConsent(agentId, agentName, apiKey, baseUrl, dashboardUrl, logger, scope, platform) {
|
|
1303
1314
|
const scopeStrings = scope ? [`${scope.service}:${scope.permissionLevel}`] : detectScopeHints();
|
|
1304
|
-
const consentUrl = buildConsentUrl(agentName, scopeStrings, dashboardUrl);
|
|
1315
|
+
const consentUrl = buildConsentUrl(agentName, scopeStrings, dashboardUrl, platform);
|
|
1305
1316
|
logger.info("Opening consent page in your browser.", { url: consentUrl });
|
|
1306
1317
|
process.stderr.write(
|
|
1307
1318
|
`
|
|
@@ -1325,7 +1336,7 @@ Waiting for you to grant access in the Multicorn dashboard...
|
|
|
1325
1336
|
`Consent not granted within ${String(CONSENT_POLL_TIMEOUT_MS / 6e4)} minutes. Grant access at ${dashboardUrl} and restart the proxy.`
|
|
1326
1337
|
);
|
|
1327
1338
|
}
|
|
1328
|
-
async function resolveAgentRecord(agentName, apiKey, baseUrl, logger) {
|
|
1339
|
+
async function resolveAgentRecord(agentName, apiKey, baseUrl, logger, platform) {
|
|
1329
1340
|
const cachedScopes = await loadCachedScopes(agentName, apiKey);
|
|
1330
1341
|
if (cachedScopes !== null && cachedScopes.length > 0) {
|
|
1331
1342
|
logger.debug("Loaded scopes from cache.", { agent: agentName, count: cachedScopes.length });
|
|
@@ -1338,7 +1349,7 @@ async function resolveAgentRecord(agentName, apiKey, baseUrl, logger) {
|
|
|
1338
1349
|
if (agent === null) {
|
|
1339
1350
|
try {
|
|
1340
1351
|
logger.info("Agent not found. Registering.", { agent: agentName });
|
|
1341
|
-
const id = await registerAgent(agentName, apiKey, baseUrl);
|
|
1352
|
+
const id = await registerAgent(agentName, apiKey, baseUrl, platform);
|
|
1342
1353
|
agent = { id, name: agentName, scopes: [] };
|
|
1343
1354
|
logger.info("Agent registered.", { agent: agentName, id });
|
|
1344
1355
|
} catch (error) {
|
|
@@ -1358,12 +1369,15 @@ async function resolveAgentRecord(agentName, apiKey, baseUrl, logger) {
|
|
|
1358
1369
|
}
|
|
1359
1370
|
return { ...agent, scopes };
|
|
1360
1371
|
}
|
|
1361
|
-
function buildConsentUrl(agentName, scopes, dashboardUrl) {
|
|
1372
|
+
function buildConsentUrl(agentName, scopes, dashboardUrl, platform) {
|
|
1362
1373
|
const base = dashboardUrl.replace(/\/+$/, "");
|
|
1363
1374
|
const params = new URLSearchParams({ agent: agentName });
|
|
1364
1375
|
if (scopes.length > 0) {
|
|
1365
1376
|
params.set("scopes", scopes.join(","));
|
|
1366
1377
|
}
|
|
1378
|
+
if (platform) {
|
|
1379
|
+
params.set("platform", platform);
|
|
1380
|
+
}
|
|
1367
1381
|
return `${base}/consent?${params.toString()}`;
|
|
1368
1382
|
}
|
|
1369
1383
|
function detectScopeHints() {
|
|
@@ -1390,7 +1404,7 @@ function isAgentDetailShape(value) {
|
|
|
1390
1404
|
function isPermissionShape(value) {
|
|
1391
1405
|
if (typeof value !== "object" || value === null) return false;
|
|
1392
1406
|
const obj = value;
|
|
1393
|
-
return typeof obj["service"] === "string" && typeof obj["read"] === "boolean" && typeof obj["write"] === "boolean" && typeof obj["execute"] === "boolean" && (obj["revoked_at"] === null || typeof obj["revoked_at"] === "string");
|
|
1407
|
+
return typeof obj["service"] === "string" && typeof obj["read"] === "boolean" && typeof obj["write"] === "boolean" && typeof obj["execute"] === "boolean" && (obj["revoked_at"] === null || obj["revoked_at"] === void 0 || typeof obj["revoked_at"] === "string");
|
|
1394
1408
|
}
|
|
1395
1409
|
|
|
1396
1410
|
// src/proxy/index.ts
|
|
@@ -1445,7 +1459,8 @@ function createProxyServer(config) {
|
|
|
1445
1459
|
config.baseUrl,
|
|
1446
1460
|
config.dashboardUrl,
|
|
1447
1461
|
config.logger,
|
|
1448
|
-
scopeParam
|
|
1462
|
+
scopeParam,
|
|
1463
|
+
config.platform ?? "other-mcp"
|
|
1449
1464
|
);
|
|
1450
1465
|
grantedScopes = scopes;
|
|
1451
1466
|
await saveCachedScopes(config.agentName, agentId, scopes, config.apiKey);
|
|
@@ -1596,7 +1611,8 @@ function createProxyServer(config) {
|
|
|
1596
1611
|
config.agentName,
|
|
1597
1612
|
config.apiKey,
|
|
1598
1613
|
config.baseUrl,
|
|
1599
|
-
config.logger
|
|
1614
|
+
config.logger,
|
|
1615
|
+
config.platform ?? "other-mcp"
|
|
1600
1616
|
);
|
|
1601
1617
|
agentId = agentRecord.id;
|
|
1602
1618
|
grantedScopes = agentRecord.scopes;
|
|
@@ -1823,7 +1839,8 @@ Use https:// or http://localhost for local development.
|
|
|
1823
1839
|
agentName,
|
|
1824
1840
|
baseUrl: finalBaseUrl,
|
|
1825
1841
|
dashboardUrl: finalDashboardUrl,
|
|
1826
|
-
logger
|
|
1842
|
+
logger,
|
|
1843
|
+
platform: "other-mcp"
|
|
1827
1844
|
});
|
|
1828
1845
|
async function shutdown() {
|
|
1829
1846
|
logger.info("Shutting down.");
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, mkdir, writeFile } from 'fs/promises';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import 'fs';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import 'readline';
|
|
7
|
+
|
|
8
|
+
var CONFIG_DIR = join(homedir(), ".multicorn");
|
|
9
|
+
join(CONFIG_DIR, "config.json");
|
|
10
|
+
join(homedir(), ".openclaw", "openclaw.json");
|
|
11
|
+
function getClaudeDesktopConfigPath() {
|
|
12
|
+
switch (process.platform) {
|
|
13
|
+
case "win32":
|
|
14
|
+
return join(
|
|
15
|
+
process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming"),
|
|
16
|
+
"Claude",
|
|
17
|
+
"claude_desktop_config.json"
|
|
18
|
+
);
|
|
19
|
+
case "linux":
|
|
20
|
+
return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
21
|
+
default:
|
|
22
|
+
return join(
|
|
23
|
+
homedir(),
|
|
24
|
+
"Library",
|
|
25
|
+
"Application Support",
|
|
26
|
+
"Claude",
|
|
27
|
+
"claude_desktop_config.json"
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
var EXTENSION_BACKUP_FILENAME = "extension-backup.json";
|
|
32
|
+
function getExtensionBackupPath() {
|
|
33
|
+
return join(homedir(), ".multicorn", EXTENSION_BACKUP_FILENAME);
|
|
34
|
+
}
|
|
35
|
+
function isRecord(value) {
|
|
36
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
37
|
+
}
|
|
38
|
+
async function readExtensionBackup() {
|
|
39
|
+
try {
|
|
40
|
+
const raw = await readFile(getExtensionBackupPath(), "utf8");
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
if (!isRecord(parsed)) return null;
|
|
43
|
+
if (parsed["version"] !== 1) return null;
|
|
44
|
+
if (typeof parsed["createdAt"] !== "string") return null;
|
|
45
|
+
if (typeof parsed["claudeDesktopConfigPath"] !== "string") return null;
|
|
46
|
+
const mcpServers = parsed["mcpServers"];
|
|
47
|
+
if (!isRecord(mcpServers)) return null;
|
|
48
|
+
return {
|
|
49
|
+
version: 1,
|
|
50
|
+
createdAt: parsed["createdAt"],
|
|
51
|
+
claudeDesktopConfigPath: parsed["claudeDesktopConfigPath"],
|
|
52
|
+
mcpServers
|
|
53
|
+
};
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/extension/restore.ts
|
|
60
|
+
function isErrnoException(e) {
|
|
61
|
+
return typeof e === "object" && e !== null && "code" in e;
|
|
62
|
+
}
|
|
63
|
+
function isRecord2(value) {
|
|
64
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
65
|
+
}
|
|
66
|
+
async function restoreClaudeDesktopMcpFromBackup() {
|
|
67
|
+
const backup = await readExtensionBackup();
|
|
68
|
+
if (backup === null) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
"No Shield extension backup found. Expected ~/.multicorn/extension-backup.json from a previous Shield Desktop Extension session."
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const configPath = getClaudeDesktopConfigPath();
|
|
74
|
+
let root = {};
|
|
75
|
+
try {
|
|
76
|
+
const raw = await readFile(configPath, "utf8");
|
|
77
|
+
const parsed = JSON.parse(raw);
|
|
78
|
+
if (isRecord2(parsed)) {
|
|
79
|
+
root = parsed;
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (!isErrnoException(error) || error.code !== "ENOENT") {
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
root["mcpServers"] = backup.mcpServers;
|
|
87
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
88
|
+
await writeFile(configPath, JSON.stringify(root, null, 2) + "\n", { encoding: "utf8" });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// bin/multicorn-shield.ts
|
|
92
|
+
async function main() {
|
|
93
|
+
const arg = process.argv[2];
|
|
94
|
+
if (arg === "restore") {
|
|
95
|
+
await restoreClaudeDesktopMcpFromBackup();
|
|
96
|
+
process.stderr.write(
|
|
97
|
+
"Restored MCP server entries from ~/.multicorn/extension-backup.json into Claude Desktop config.\nRestart Claude Desktop to apply changes.\n"
|
|
98
|
+
);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
process.stderr.write(
|
|
102
|
+
[
|
|
103
|
+
"Multicorn Shield command-line tool",
|
|
104
|
+
"",
|
|
105
|
+
"Usage:",
|
|
106
|
+
" npx multicorn-shield restore",
|
|
107
|
+
" Restore MCP servers in claude_desktop_config.json from the Shield extension backup.",
|
|
108
|
+
""
|
|
109
|
+
].join("\n")
|
|
110
|
+
);
|
|
111
|
+
process.exit(arg === void 0 || arg === "help" || arg === "--help" ? 0 : 1);
|
|
112
|
+
}
|
|
113
|
+
void main().catch((error) => {
|
|
114
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
115
|
+
process.stderr.write(`Error: ${message}
|
|
116
|
+
`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
});
|
|
@@ -178,7 +178,7 @@ function isAgentDetail(value) {
|
|
|
178
178
|
function isPermissionEntry(value) {
|
|
179
179
|
if (typeof value !== "object" || value === null) return false;
|
|
180
180
|
const obj = value;
|
|
181
|
-
return typeof obj["service"] === "string" && typeof obj["read"] === "boolean" && typeof obj["write"] === "boolean" && typeof obj["execute"] === "boolean" && (obj["revoked_at"] === null || typeof obj["revoked_at"] === "string");
|
|
181
|
+
return typeof obj["service"] === "string" && typeof obj["read"] === "boolean" && typeof obj["write"] === "boolean" && typeof obj["execute"] === "boolean" && (obj["revoked_at"] === null || obj["revoked_at"] === void 0 || typeof obj["revoked_at"] === "string");
|
|
182
182
|
}
|
|
183
183
|
function handleHttpError(status, logger, retryDelaySeconds) {
|
|
184
184
|
if (status === 401 || status === 403) {
|
|
@@ -233,7 +233,7 @@ async function registerAgent(agentName, apiKey, baseUrl, logger) {
|
|
|
233
233
|
"Content-Type": "application/json",
|
|
234
234
|
[AUTH_HEADER]: apiKey
|
|
235
235
|
},
|
|
236
|
-
body: JSON.stringify({ name: agentName }),
|
|
236
|
+
body: JSON.stringify({ name: agentName, platform: "openclaw" }),
|
|
237
237
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
238
238
|
});
|
|
239
239
|
if (!response.ok) {
|
|
@@ -183,7 +183,7 @@ function isAgentDetail(value) {
|
|
|
183
183
|
function isPermissionEntry(value) {
|
|
184
184
|
if (typeof value !== "object" || value === null) return false;
|
|
185
185
|
const obj = value;
|
|
186
|
-
return typeof obj["service"] === "string" && typeof obj["read"] === "boolean" && typeof obj["write"] === "boolean" && typeof obj["execute"] === "boolean" && (obj["revoked_at"] === null || typeof obj["revoked_at"] === "string");
|
|
186
|
+
return typeof obj["service"] === "string" && typeof obj["read"] === "boolean" && typeof obj["write"] === "boolean" && typeof obj["execute"] === "boolean" && (obj["revoked_at"] === null || obj["revoked_at"] === void 0 || typeof obj["revoked_at"] === "string");
|
|
187
187
|
}
|
|
188
188
|
function handleHttpError(status, logger, retryDelaySeconds) {
|
|
189
189
|
if (status === 401 || status === 403) {
|
|
@@ -241,7 +241,7 @@ async function registerAgent(agentName, apiKey, baseUrl, logger) {
|
|
|
241
241
|
"Content-Type": "application/json",
|
|
242
242
|
[AUTH_HEADER]: apiKey
|
|
243
243
|
},
|
|
244
|
-
body: JSON.stringify({ name: agentName }),
|
|
244
|
+
body: JSON.stringify({ name: agentName, platform: "openclaw" }),
|
|
245
245
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
246
246
|
});
|
|
247
247
|
if (!response.ok) {
|