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 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.
@@ -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 = { apiKey, baseUrl };
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) {