@unfragile/mcp-server 0.5.0 → 0.6.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/README.md CHANGED
@@ -57,8 +57,30 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
57
57
  }
58
58
  ```
59
59
 
60
+ ## Verify before you install (CLI guard)
61
+
62
+ Don't add an MCP server blind. Verify it first — identity, known security incidents, data-access risk, and cross-vendor reputation, in one signed verdict:
63
+
64
+ ```bash
65
+ # Check a target before installing
66
+ npx -y @unfragile/mcp-server verify mcp:@modelcontextprotocol/server-postgres
67
+
68
+ # Guard a real install — runs the command only if the target isn't FLAGGED
69
+ npx -y @unfragile/mcp-server guard -- claude mcp add pg -- npx -y @scope/pg-mcp
70
+ ```
71
+
72
+ The `guard` wrapper extracts the install target from your `claude mcp add` / `cursor mcp add` / `npx` command, verifies it, and **blocks on a `flagged` verdict** (known-bad package, exfiltration, typosquat) unless you pass `--force`. Every verdict is Ed25519-signed and audit-logged — defensible evidence for compliance (EU AI Act Annex III logging/oversight).
73
+
74
+ Verdicts: `trusted` · `caution` · `unverified` · `flagged`.
75
+
60
76
  ## Tools
61
77
 
78
+ ### Verify (the trust resolver)
79
+
80
+ | Tool | Description |
81
+ |------|-------------|
82
+ | `unfragile_verify` | **Call BEFORE invoking or installing any tool/MCP server.** Returns a signed verdict (`trusted`/`caution`/`unverified`/`flagged`) from identity + typosquat checks, the MCP CVE/incident registry, structural data-access risk, and cross-vendor match-graph reputation. Do not invoke anything that comes back `flagged`. |
83
+
62
84
  ### Resolve (the headline call)
63
85
 
64
86
  | Tool | Description |
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/guard.js ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ // ─────────────────────────────────────────────────────────────
3
+ // `unfragile` — verify-before-invoke CLI guard.
4
+ //
5
+ // The viral dev hook: verify an MCP server / package BEFORE you install it.
6
+ //
7
+ // npx -y @unfragile/mcp-server verify mcp:@scope/server
8
+ // npx -y @unfragile/mcp-server guard -- claude mcp add pg -- npx -y @scope/pg-mcp
9
+ //
10
+ // `verify <target>` → prints the signed verdict; exit 1 if flagged.
11
+ // `guard -- <cmd…>` → extracts the install target from a `claude mcp add`
12
+ // (or any `npx …`) command, verifies it, BLOCKS on
13
+ // `flagged` (unless --force), then runs the real command.
14
+ // ─────────────────────────────────────────────────────────────
15
+ import { spawn } from "node:child_process";
16
+ const API_BASE = process.env.UNFRAGILE_API_URL || "https://unfragile.ai";
17
+ const API_KEY = process.env.UNFRAGILE_API_KEY || "";
18
+ const C = {
19
+ reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
20
+ green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", gray: "\x1b[90m",
21
+ };
22
+ const isTTY = process.stdout.isTTY;
23
+ const paint = (s, ...codes) => (isTTY ? codes.join("") + s + C.reset : s);
24
+ /** Pull the most likely install target out of a wrapped command's args. */
25
+ function extractTarget(args) {
26
+ const joined = args.join(" ");
27
+ // npx [-y] <pkg> (the common MCP-add shape)
28
+ const npx = joined.match(/npx\s+(?:-y\s+|--yes\s+)?(@?[\w./-]+)/);
29
+ if (npx)
30
+ return `npm:${npx[1]}`;
31
+ // uvx / pipx <pkg>
32
+ const uvx = joined.match(/\b(?:uvx|pipx run)\s+(@?[\w./-]+)/);
33
+ if (uvx)
34
+ return `pypi:${uvx[1]}`;
35
+ // an explicit prefixed ref or URL anywhere in the args
36
+ const ref = args.find((a) => /^(mcp|npm|pypi|github|gh):/.test(a) || /^https?:\/\//.test(a));
37
+ if (ref)
38
+ return ref;
39
+ return null;
40
+ }
41
+ async function callVerify(target, intent, action) {
42
+ const res = await fetch(`${API_BASE}/api/v1/verify`, {
43
+ method: "POST",
44
+ headers: { "Content-Type": "application/json", ...(API_KEY ? { "X-API-Key": API_KEY } : {}) },
45
+ body: JSON.stringify({ target, intent, action }),
46
+ signal: AbortSignal.timeout(15000),
47
+ });
48
+ if (!res.ok)
49
+ throw new Error(`HTTP ${res.status}`);
50
+ return (await res.json());
51
+ }
52
+ function printVerdict(target, v) {
53
+ const verdict = (v.verdict || "unverified");
54
+ const badge = verdict === "trusted" ? paint(" TRUSTED ", C.bold, C.green)
55
+ : verdict === "caution" ? paint(" CAUTION ", C.bold, C.yellow)
56
+ : verdict === "flagged" ? paint(" FLAGGED ", C.bold, C.red)
57
+ : paint(" UNVERIFIED ", C.bold, C.gray);
58
+ console.error("");
59
+ console.error(` ${badge} ${paint(target, C.bold)}`);
60
+ if (v.summary)
61
+ console.error(` ${paint(v.summary, C.dim)}`);
62
+ const s = v.safety || {};
63
+ if (s.score != null)
64
+ console.error(` ${paint("safety", C.gray)} ${s.score}/100 · ${s.dataAccessRisk || "?"} data-access risk`);
65
+ if (s.flags?.length)
66
+ console.error(` ${paint("flags", C.gray)} ${paint(s.flags.join(", "), C.yellow)}`);
67
+ const id = v.identity || {};
68
+ if (id.typosquatRisk)
69
+ console.error(` ${paint("⚠ resembles '" + id.typosquatRisk + "' — possible typosquat", C.yellow)}`);
70
+ const r = v.reputation || {};
71
+ if (r.timesInvoked)
72
+ console.error(` ${paint("rep", C.gray)} ${r.timesInvoked} invocations${r.successRate != null ? `, ${Math.round(r.successRate * 100)}% success` : ""} · rank ${r.unfragileRank ?? 0}/100`);
73
+ if (v.signature)
74
+ console.error(` ${paint("signed by " + (v.signedBy || "unfragile.ai") + " (Ed25519)", C.gray)}`);
75
+ if (v.compliance?.evidenceUrl)
76
+ console.error(` ${paint("audit " + v.compliance.evidenceUrl, C.gray)}`);
77
+ console.error("");
78
+ return verdict;
79
+ }
80
+ function help() {
81
+ console.error(`unfragile — verify-before-invoke guard
82
+
83
+ USAGE
84
+ unfragile verify <target> [--intent "..."] [--action read|write|execute]
85
+ unfragile guard [--force] -- <command...>
86
+
87
+ EXAMPLES
88
+ unfragile verify mcp:@modelcontextprotocol/server-postgres
89
+ unfragile verify npm:postmark-mcp
90
+ unfragile guard -- claude mcp add pg -- npx -y @scope/pg-mcp
91
+ unfragile guard -- cursor mcp add ...
92
+
93
+ BEHAVIOR
94
+ Prints a signed verdict (trusted | caution | unverified | flagged).
95
+ 'guard' runs your command only if the target is not FLAGGED.
96
+ Use --force to override a flagged target (logged). Exit 1 on flagged/block.
97
+
98
+ ENV
99
+ UNFRAGILE_API_URL (default https://unfragile.ai)
100
+ UNFRAGILE_API_KEY (optional — higher rate limits)`);
101
+ }
102
+ function getFlag(args, name) {
103
+ const i = args.indexOf(name);
104
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
105
+ }
106
+ async function main() {
107
+ const argv = process.argv.slice(2);
108
+ const cmd = argv[0];
109
+ if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") {
110
+ help();
111
+ process.exit(cmd ? 0 : 1);
112
+ }
113
+ if (cmd === "verify") {
114
+ const target = argv[1];
115
+ if (!target || target.startsWith("-")) {
116
+ console.error("error: `unfragile verify <target>` requires a target.");
117
+ process.exit(1);
118
+ }
119
+ const intent = getFlag(argv, "--intent");
120
+ const action = getFlag(argv, "--action");
121
+ try {
122
+ const v = await callVerify(target, intent, action);
123
+ const verdict = printVerdict(target, v);
124
+ process.exit(verdict === "flagged" ? 1 : 0);
125
+ }
126
+ catch (err) {
127
+ console.error(paint(` verify unavailable (${err instanceof Error ? err.message : String(err)}) — treat as UNVERIFIED.`, C.yellow));
128
+ process.exit(2);
129
+ }
130
+ }
131
+ if (cmd === "guard") {
132
+ const force = argv.includes("--force");
133
+ const sep = argv.indexOf("--");
134
+ if (sep < 0 || sep + 1 >= argv.length) {
135
+ console.error("error: `unfragile guard -- <command...>` requires a command after `--`.");
136
+ process.exit(1);
137
+ }
138
+ const wrapped = argv.slice(sep + 1);
139
+ const target = extractTarget(wrapped);
140
+ if (!target) {
141
+ console.error(paint(" no install target detected in command — running unguarded.", C.gray));
142
+ }
143
+ else {
144
+ try {
145
+ const v = await callVerify(target, undefined, "execute");
146
+ const verdict = printVerdict(target, v);
147
+ if (verdict === "flagged" && !force) {
148
+ console.error(paint(" ✗ BLOCKED — this target is flagged. Re-run with --force to override (logged).", C.bold + C.red));
149
+ process.exit(1);
150
+ }
151
+ if (verdict === "flagged" && force) {
152
+ console.error(paint(" ⚠ proceeding despite FLAGGED verdict (--force).", C.yellow));
153
+ }
154
+ }
155
+ catch (err) {
156
+ console.error(paint(` verify unavailable (${err instanceof Error ? err.message : String(err)}) — proceeding unguarded.`, C.yellow));
157
+ }
158
+ }
159
+ // Pass through to the real command.
160
+ const child = spawn(wrapped[0], wrapped.slice(1), { stdio: "inherit", shell: false });
161
+ child.on("exit", (code) => process.exit(code ?? 0));
162
+ child.on("error", (e) => {
163
+ console.error(`error running command: ${e.message}`);
164
+ process.exit(1);
165
+ });
166
+ return;
167
+ }
168
+ console.error(`unknown command '${cmd}'.\n`);
169
+ help();
170
+ process.exit(1);
171
+ }
172
+ main();
package/dist/index.js CHANGED
@@ -489,6 +489,56 @@ server.tool("trust_passport", "Get the machine-readable trust passport for an AI
489
489
  return { content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
490
490
  }
491
491
  });
492
+ // Tool 5b: Verify-before-invoke — the trust resolver
493
+ server.tool("unfragile_verify", "Verify a tool / MCP server / package BEFORE invoking or installing it. Returns a signed verdict: trusted | caution | unverified | flagged. Pass the install target (e.g. 'mcp:@scope/server', 'npm:pkg', a GitHub/URL, or an `npx ...` command). Checks identity + typosquats, known security incidents/CVEs, data-access risk, and cross-vendor reputation. ALWAYS call this before adding an MCP server or running an unfamiliar tool; do NOT invoke anything that comes back 'flagged'.", {
494
+ target: z.string().min(1).max(500).describe("What you're about to invoke/install: 'mcp:@scope/server', 'npm:pkg', 'pypi:pkg', 'github:owner/repo', an https URL, an `npx -y @scope/pkg` command, or a bare slug."),
495
+ intent: z.string().max(300).optional().describe("What you want to do with it (e.g. 'query postgres read-only') — enables fit scoring."),
496
+ action: z.enum(["read", "write", "execute"]).optional().describe("The kind of access you intend to grant — raises the risk bar for write/execute."),
497
+ }, async ({ target, intent, action }) => {
498
+ log("unfragile_verify", target);
499
+ try {
500
+ const res = await fetch(`${API_BASE}/api/v1/verify`, {
501
+ method: "POST",
502
+ headers: {
503
+ "Content-Type": "application/json",
504
+ ...(API_KEY ? { "X-API-Key": API_KEY } : {}),
505
+ },
506
+ body: JSON.stringify({ target, intent, action }),
507
+ signal: AbortSignal.timeout(15000),
508
+ });
509
+ if (!res.ok) {
510
+ return { content: [{ type: "text", text: `Verify failed (HTTP ${res.status}). Treat '${target}' as UNVERIFIED — do not invoke without manual review.` }], isError: true };
511
+ }
512
+ const v = await res.json();
513
+ const icon = v.verdict === "trusted" ? "✅" : v.verdict === "caution" ? "⚠️" : v.verdict === "flagged" ? "🛑" : "❓";
514
+ const lines = [];
515
+ lines.push(`${icon} VERDICT: ${String(v.verdict || "unverified").toUpperCase()}`);
516
+ lines.push(v.summary || "");
517
+ const id = v.identity || {};
518
+ lines.push(`\nIdentity: ${id.matched ? `matched → ${id.canonical}` : "not found in graph"}${id.verifiedBuilder ? " (verified builder)" : ""}${id.typosquatRisk ? ` ⚠️ resembles '${id.typosquatRisk}' — possible typosquat` : ""}`);
519
+ const s = v.safety || {};
520
+ lines.push(`Safety: ${s.score ?? "?"}/100 · ${s.dataAccessRisk || "?"} data-access risk · permissions: ${(s.permissions || []).join(", ") || "unknown"}`);
521
+ if (s.flags && s.flags.length)
522
+ lines.push(`Flags: ${s.flags.join(", ")}`);
523
+ const r = v.reputation || {};
524
+ if (r.timesInvoked)
525
+ lines.push(`Reputation: ${r.timesInvoked} invocations${r.successRate != null ? `, ${Math.round(r.successRate * 100)}% success` : " (no rated history yet)"} · rank ${r.unfragileRank ?? 0}/100`);
526
+ if (v.verdict === "flagged")
527
+ lines.push(`\n🛑 DO NOT INVOKE. ${s.flags?.includes("known-exfiltration") ? "Known data-exfiltration incident." : "Known security issue."} Verify the publisher and pin a known-good version first.`);
528
+ else if (v.verdict === "unverified")
529
+ lines.push(`\n❓ Not attested by Unfragile. No identity/outcome history. Proceed only with manual review.`);
530
+ else if (v.verdict === "caution")
531
+ lines.push(`\n⚠️ Usable but unproven or write/exec-capable. Review permissions before granting access.`);
532
+ if (v.signature)
533
+ lines.push(`\nSigned by ${v.signedBy || "unfragile.ai"} (Ed25519, offline-verifiable). Verify key: ${v._links?.verify_key || `${API_BASE}/api/v1/trust-passport-public-key`}`);
534
+ if (v.compliance?.evidenceUrl)
535
+ lines.push(`Audit evidence: ${v.compliance.evidenceUrl}`);
536
+ return { content: [{ type: "text", text: lines.filter(Boolean).join("\n") }] };
537
+ }
538
+ catch (err) {
539
+ return { content: [{ type: "text", text: `Verify error: ${err instanceof Error ? err.message : String(err)}. Treat '${target}' as UNVERIFIED — do not invoke without manual review.` }], isError: true };
540
+ }
541
+ });
492
542
  // Tool 6: Compare artifacts
493
543
  server.tool("compare", "Compare two AI artifacts side-by-side. Shows capabilities, pricing, rank, and graph signals for each. Uses search-based lookup (best-effort name matching). Use this when deciding between alternatives.", {
494
544
  artifact_a: z.string().min(1).max(200).describe("First artifact name (e.g., 'cursor')"),
@@ -622,7 +672,10 @@ server.tool("feedback", "Report whether a recommended tool worked or not. This c
622
672
  headers["X-API-Key"] = API_KEY;
623
673
  const body = {
624
674
  matchRecordId,
625
- outcome: outcome === "success" ? "success" : "failure",
675
+ // Canonical outcome values are "success" | "fail" (see MatchOutcome in
676
+ // the schema). The tool exposes "failure" to agents for clarity, but we
677
+ // MUST send "fail" — otherwise the route can't lower successRate.
678
+ outcome: outcome === "success" ? "success" : "fail",
626
679
  clickedThrough: true,
627
680
  source: SOURCE,
628
681
  };
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@unfragile/mcp-server",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "mcpName": "io.github.Savirinc/unfragile",
5
- "description": "Unfragile MCP Server — agent-native discovery, trust, and routing for AI artifacts. Implements the Unfragile Capability Protocol v1.",
5
+ "description": "Unfragile MCP Server — verify-before-invoke trust resolver + agent-native discovery and routing for AI artifacts. Implements the Unfragile Capability Protocol v1.",
6
6
  "keywords": [
7
7
  "mcp",
8
8
  "ai",
@@ -26,13 +26,14 @@
26
26
  "type": "module",
27
27
  "main": "dist/index.js",
28
28
  "bin": {
29
- "unfragile-mcp": "dist/index.js"
29
+ "unfragile-mcp": "dist/index.js",
30
+ "unfragile": "dist/guard.js"
30
31
  },
31
32
  "files": [
32
33
  "dist"
33
34
  ],
34
35
  "scripts": {
35
- "build": "tsc && chmod +x dist/index.js",
36
+ "build": "tsc && chmod +x dist/index.js dist/guard.js",
36
37
  "dev": "tsx src/index.ts",
37
38
  "start": "node dist/index.js",
38
39
  "prepublishOnly": "npm run build"