@grantor/mcp
Advanced tools
| // demo.js — the first denial, in your own terminal, ~30 seconds. | ||
| // Wraps the bundled toy server through the REAL wrap machinery (grant mint, | ||
| // relay, enforcement, chain-read revocation check — nothing simulated except | ||
| // the client), lets one allowed call through, then watches `delete_everything` | ||
| // get refused before the server ever sees it. State lives in a temp file so | ||
| // the demo never touches the user's broker state. | ||
| import { spawn } from "node:child_process"; | ||
| import { mkdtempSync, rmSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join, dirname } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { PassThrough } from "node:stream"; | ||
| import { wrapVerb } from "../wrap/run.js"; | ||
| import { lineSplit } from "../wrap/proxy.js"; | ||
| import { loadState } from "../state.js"; | ||
| const TOY = join(dirname(fileURLToPath(import.meta.url)), "..", "wrap", "toy-server.js"); | ||
| export async function demoVerb(ctx, _args = {}, deps = {}) { | ||
| const say = deps.say ?? ((m) => console.error(m)); | ||
| const tmp = mkdtempSync(join(tmpdir(), "grantor-mcp-demo-")); | ||
| const statePath = join(tmp, "state.json"); | ||
| const demoCtx = { ...ctx, statePath, state: loadState(statePath) }; | ||
| // The scripted "agent": three requests through the real relay. | ||
| const clientIn = new PassThrough(); | ||
| const clientOut = new PassThrough(); | ||
| const responses = new Map(); // id -> resolve | ||
| lineSplit(clientOut, (line) => { | ||
| try { | ||
| const msg = JSON.parse(line); | ||
| if (msg.id !== undefined && responses.has(msg.id)) { | ||
| responses.get(msg.id)(msg); | ||
| responses.delete(msg.id); | ||
| } | ||
| } catch { /* ignore non-JSON */ } | ||
| }); | ||
| const request = (msg, timeoutMs = 30_000) => | ||
| new Promise((resolve, reject) => { | ||
| const t = setTimeout(() => reject(new Error(`demo: no response to ${msg.method} within ${timeoutMs}ms`)), timeoutMs); | ||
| responses.set(msg.id, (m) => { clearTimeout(t); resolve(m); }); | ||
| clientIn.write(`${JSON.stringify(msg)}\n`); | ||
| }); | ||
| say("grantor-mcp demo — the first denial, live in this terminal."); | ||
| say(""); | ||
| say("Wrapping a toy MCP server behind a grant for ONE tool: `search`."); | ||
| say("The server also exposes `delete_everything`. Watch what happens."); | ||
| say(""); | ||
| // Capture the toy child through the injectable spawn seam: a wrapped MCP | ||
| // server normally outlives the script (the relay never closes the child's | ||
| // stdin — servers are long-lived), so the demo must kill it explicitly or | ||
| // wrapVerb's promise never resolves. | ||
| let toyProc; | ||
| const spawnCapture = (...a) => { toyProc = spawn(...a); return toyProc; }; | ||
| const done = wrapVerb( | ||
| demoCtx, | ||
| { tools: ["search"], max_uses: 5, ttl_secs: 600, childCmd: [process.execPath, TOY] }, | ||
| { | ||
| io: { stdin: clientIn, stdout: clientOut }, | ||
| spawnImpl: spawnCapture, | ||
| checkImpl: deps.checkImpl, // default: the real chain-reading checkVerb | ||
| log: (m) => say(` ${m}`), | ||
| }, | ||
| ); | ||
| let exitCode = 0; | ||
| try { | ||
| await request({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "demo-agent", version: "0" } } }); | ||
| clientIn.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`); | ||
| say(""); | ||
| say('The "agent" calls the tool it was granted:'); | ||
| const ok = await request({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "search", arguments: { query: "anything" } } }); | ||
| say(` → ALLOWED, served by the wrapped server: ${ok.result?.content?.[0]?.text ?? JSON.stringify(ok.result ?? ok.error)}`); | ||
| say(""); | ||
| say("Now it tries the one it was NOT granted:"); | ||
| const denied = await request({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "delete_everything", arguments: {} } }); | ||
| const deniedOk = denied.result?.isError === true; | ||
| say(""); | ||
| say(deniedOk | ||
| ? "That box is the product: the grant said search-only, so the broker" | ||
| : "UNEXPECTED: the call was not denied — this is a bug, please report it."); | ||
| if (deniedOk) { | ||
| say("answered the call itself. The server never saw it — there is no"); | ||
| say("prompt to talk your way past."); | ||
| say(""); | ||
| say("Next, on your own server:"); | ||
| say(" npx -y @grantor/mcp tools -- <your MCP server command>"); | ||
| say(" (lists its tools and suggests a ready-to-paste wrap command)"); | ||
| say("Guide: https://chaingrantor.com/docs/guide/first-denial.html"); | ||
| } else { | ||
| exitCode = 1; | ||
| } | ||
| } catch (e) { | ||
| say(`demo failed: ${e?.message ?? e}`); | ||
| exitCode = 1; | ||
| } finally { | ||
| clientIn.end(); | ||
| toyProc?.stdin?.end(); // toy exits on ITS stdin end → wrapVerb resolves | ||
| const timeout = setTimeout(() => toyProc?.kill("SIGKILL"), 3000); | ||
| await done.catch(() => {}); | ||
| clearTimeout(timeout); | ||
| rmSync(tmp, { recursive: true, force: true }); | ||
| } | ||
| process.exitCode = exitCode; | ||
| return undefined; // human output only — no JSON result line | ||
| } |
| // tools.js — inspect an MCP server and SUGGEST a wrap command. This is the | ||
| // honest form of a "read-only preset": the broker's grant grammar names | ||
| // tools explicitly (no wildcards, on purpose — monotone narrowing needs | ||
| // concrete names), so a preset cannot know a server's tool names in advance. | ||
| // This verb discovers them, splits read-like from write-like BY NAME | ||
| // HEURISTIC, and prints a ready-to-paste suggestion the user reviews — | ||
| // informed consent, not magic granting. Needs no broker config/state/chain. | ||
| import { spawn } from "node:child_process"; | ||
| import { lineSplit } from "../wrap/proxy.js"; | ||
| // Name-heuristic ONLY — the suggestion says so. Write-ish wins over read-ish | ||
| // (a `delete_read_marker` is write-ish). | ||
| const WRITEY = /(write|creat|delet|remov|updat|move|renam|exec|run|shell|command|spawn|send|post|put|patch|insert|drop|kill|install|deploy|push|publish|set_|^set$|upload|edit|append|mkdir|rm_|^rm$|clear|reset|revoke|approve|transfer|sign)/i; | ||
| export function splitByRisk(names) { | ||
| const readLike = [], writeLike = []; | ||
| for (const n of names) (WRITEY.test(n) ? writeLike : readLike).push(n); | ||
| return { readLike, writeLike }; | ||
| } | ||
| export async function toolsVerb({ childCmd }, deps = {}) { | ||
| const say = deps.say ?? ((m) => console.log(m)); | ||
| const spawnImpl = deps.spawnImpl ?? spawn; | ||
| if (!Array.isArray(childCmd) || childCmd.length === 0) { | ||
| throw new Error("grantor-mcp tools: missing server command — usage: grantor-mcp tools -- <cmd> [args...]"); | ||
| } | ||
| const proc = spawnImpl(childCmd[0], childCmd.slice(1), { stdio: ["pipe", "pipe", "inherit"] }); | ||
| proc.stdin?.on("error", () => {}); | ||
| const responses = new Map(); | ||
| lineSplit(proc.stdout, (line) => { | ||
| try { | ||
| const msg = JSON.parse(line); | ||
| if (msg.id !== undefined && responses.has(msg.id)) { | ||
| responses.get(msg.id)(msg); | ||
| responses.delete(msg.id); | ||
| } | ||
| } catch { /* ignore */ } | ||
| }); | ||
| const request = (msg, timeoutMs = 20_000) => | ||
| new Promise((resolve, reject) => { | ||
| const t = setTimeout(() => reject(new Error(`grantor-mcp tools: no response to ${msg.method} within ${timeoutMs}ms — is this an MCP stdio server?`)), timeoutMs); | ||
| responses.set(msg.id, (m) => { clearTimeout(t); resolve(m); }); | ||
| proc.stdin.write(`${JSON.stringify(msg)}\n`); | ||
| }); | ||
| try { | ||
| await request({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "grantor-mcp-tools", version: "0" } } }); | ||
| proc.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`); | ||
| const listed = await request({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); | ||
| const names = (listed.result?.tools ?? []).map((t) => t.name); | ||
| const { readLike, writeLike } = splitByRisk(names); | ||
| say(`tools exposed by: ${childCmd.join(" ")}`); | ||
| for (const n of names) say(` ${WRITEY.test(n) ? "✎" : "·"} ${n}`); | ||
| say(""); | ||
| if (readLike.length === 0) { | ||
| say("no read-like tool names found — pick the grant yourself:"); | ||
| say(` npx -y @grantor/mcp wrap --tools <comma,separated> --max-uses 50 --ttl-secs 3600 -- ${childCmd.join(" ")}`); | ||
| } else { | ||
| say("suggested wrap (read-like tools only — a NAME heuristic, review it):"); | ||
| say(` npx -y @grantor/mcp wrap --tools ${readLike.join(",")} --max-uses 50 --ttl-secs 3600 -- ${childCmd.join(" ")}`); | ||
| if (writeLike.length > 0) say(`held back as write-like: ${writeLike.join(", ")}`); | ||
| } | ||
| say("every tool NOT in --tools is denied before the server sees the call."); | ||
| return undefined; | ||
| } finally { | ||
| proc.kill("SIGTERM"); | ||
| } | ||
| } |
| #!/usr/bin/env node | ||
| // toy-server.js — the demo's victim: a minimal MCP stdio server with one | ||
| // harmless tool and one scary-looking one. It exists so `grantor-mcp demo` | ||
| // can produce a real, first-person denial without touching anything real — | ||
| // `delete_everything` deletes nothing anywhere, but through the wrap the | ||
| // point is sharper: the broker denies it BEFORE this process even sees it. | ||
| // Standalone on purpose (no imports from the kit): spawned as a child. | ||
| process.stdin.setEncoding("utf8"); | ||
| let buf = ""; | ||
| const TOOLS = [ | ||
| { | ||
| name: "search", | ||
| description: "Search the toy corpus (harmless).", | ||
| inputSchema: { type: "object", properties: { query: { type: "string" } } }, | ||
| }, | ||
| { | ||
| name: "delete_everything", | ||
| description: "Delete all the things (toy — deletes nothing, anywhere).", | ||
| inputSchema: { type: "object", properties: {} }, | ||
| }, | ||
| ]; | ||
| const send = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`); | ||
| function handle(msg) { | ||
| if (msg.method === "initialize") { | ||
| send({ | ||
| jsonrpc: "2.0", id: msg.id, | ||
| result: { | ||
| protocolVersion: msg.params?.protocolVersion ?? "2025-06-18", | ||
| capabilities: { tools: {} }, | ||
| serverInfo: { name: "grantor-toy-server", version: "0.0.1" }, | ||
| }, | ||
| }); | ||
| return; | ||
| } | ||
| if (msg.method === "tools/list") { | ||
| send({ jsonrpc: "2.0", id: msg.id, result: { tools: TOOLS } }); | ||
| return; | ||
| } | ||
| if (msg.method === "tools/call") { | ||
| const name = msg.params?.name; | ||
| if (name === "search") { | ||
| const q = msg.params?.arguments?.query ?? ""; | ||
| send({ | ||
| jsonrpc: "2.0", id: msg.id, | ||
| result: { content: [{ type: "text", text: `3 toy results for "${q}"` }] }, | ||
| }); | ||
| return; | ||
| } | ||
| if (name === "delete_everything") { | ||
| send({ | ||
| jsonrpc: "2.0", id: msg.id, | ||
| result: { content: [{ type: "text", text: "everything deleted (toy — nothing actually happened)" }] }, | ||
| }); | ||
| return; | ||
| } | ||
| send({ | ||
| jsonrpc: "2.0", id: msg.id, | ||
| error: { code: -32602, message: `unknown tool ${name}` }, | ||
| }); | ||
| return; | ||
| } | ||
| // notifications and anything else: ignore | ||
| if (msg.id !== undefined) { | ||
| send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `unhandled ${msg.method}` } }); | ||
| } | ||
| } | ||
| process.stdin.on("data", (chunk) => { | ||
| buf += chunk; | ||
| let i; | ||
| while ((i = buf.indexOf("\n")) >= 0) { | ||
| const line = buf.slice(0, i).trim(); | ||
| buf = buf.slice(i + 1); | ||
| if (!line) continue; | ||
| try { handle(JSON.parse(line)); } catch { /* toy: drop garbage */ } | ||
| } | ||
| }); | ||
| process.stdin.on("end", () => process.exit(0)); |
+1
-1
| { | ||
| "name": "@grantor/mcp", | ||
| "mcpName": "com.chaingrantor/grantor-mcp", | ||
| "version": "0.1.6", | ||
| "version": "0.1.7", | ||
| "description": "Grantor permission broker for multi-agent frameworks \u2014 grant, delegate, check, revoke bounded capabilities over MCP, and wrap any stdio MCP server with enforced permissions. No authorization server anywhere.", | ||
@@ -6,0 +6,0 @@ "license": "SEE LICENSE IN LICENSE", |
+21
-0
@@ -29,2 +29,21 @@ # @grantor/mcp | ||
| ## 30 seconds: watch a denial happen | ||
| ```sh | ||
| npx -y @grantor/mcp demo | ||
| ``` | ||
| Wraps a bundled toy server behind a grant for one tool, lets the granted | ||
| call through, and shows `delete_everything` getting refused **before the | ||
| server sees it** — enforcement is real (broker + on-chain revocation read), | ||
| only the "agent" is scripted. Then inspect your own server and get a | ||
| ready-to-paste wrap suggestion: | ||
| ```sh | ||
| npx -y @grantor/mcp tools -- <your MCP server command> | ||
| ``` | ||
| The suggestion grants read-like tool names only (a name heuristic — review | ||
| it before trusting it); everything outside `--tools` is denied. | ||
| ## 60-second first run | ||
@@ -255,2 +274,4 @@ | ||
| node src/cli.js status | ||
| node src/cli.js demo # the first denial, scripted, in this terminal | ||
| node src/cli.js tools -- npx some-mcp-server # list a server's tools + suggest a wrap | ||
| ``` | ||
@@ -257,0 +278,0 @@ |
+19
-1
@@ -22,2 +22,4 @@ #!/usr/bin/env node | ||
| import { wrapVerb } from "./wrap/run.js"; | ||
| import { demoVerb } from "./verbs/demo.js"; | ||
| import { toolsVerb } from "./verbs/tools.js"; | ||
@@ -106,2 +108,18 @@ export const VERBS = { grant: grantVerb, delegate: delegateVerb, check: checkVerb, revoke: revokeVerb, status: statusVerb }; | ||
| } | ||
| if (verb === "demo") { | ||
| // The first denial, live in this terminal — wraps the bundled toy server | ||
| // through the real machinery with its own temp state. Human output only. | ||
| const ctx = buildCtx(configPath); | ||
| return demoVerb(ctx); | ||
| } | ||
| if (verb === "tools") { | ||
| // Inspect an MCP server + suggest a wrap command. Same `--` contract as | ||
| // wrap; deliberately needs NO broker config/state/chain (works before | ||
| // any setup at all). | ||
| const sep = rest.indexOf("--"); | ||
| if (sep === -1) { | ||
| throw new Error("grantor-mcp tools: missing `--` separator — usage: grantor-mcp tools -- <cmd> [args...]"); | ||
| } | ||
| return toolsVerb({ childCmd: rest.slice(sep + 1) }); | ||
| } | ||
| if (verb === "wrap") { | ||
@@ -133,3 +151,3 @@ // wrap parses its OWN argv: everything after the first bare `--` is the | ||
| throw new Error( | ||
| `grantor-mcp: unknown verb "${verb ?? ""}" — expected one of: serve, ${Object.keys(VERBS).join(", ")}, setup-sandbox, wrap`, | ||
| `grantor-mcp: unknown verb "${verb ?? ""}" — expected one of: serve, ${Object.keys(VERBS).join(", ")}, setup-sandbox, wrap, demo, tools`, | ||
| ); | ||
@@ -136,0 +154,0 @@ } |
+31
-0
@@ -66,2 +66,33 @@ // protocol.js — pure classification + rewrite helpers for the wrap proxy. | ||
| /** The denial ARTIFACT — the human-facing boxed record of a refusal, printed | ||
| * to stderr alongside (never instead of) the greppable `DENY …` log line. | ||
| * Pure: a function of what the relay already holds. This is deliberately the | ||
| * product's aha moment made visible — what was asked, what the grant allows, | ||
| * and the line that carries the thesis. */ | ||
| export function denyArtifact({ tool, method, decision = {}, grants, childId, learnUrl = "https://chaingrantor.com/docs/guide/first-denial.html" }) { | ||
| const allowed = grantedToolSummary(grants); | ||
| const requested = method ?? tool ?? "(unknown)"; | ||
| const rows = [ | ||
| ["requested", requested], | ||
| ["grant allows", allowed.length ? allowed.join(", ") : "(no tools)"], | ||
| ["refused as", (decision.code ?? "Denied") + (decision.reason ? ` — ${decision.reason}` : "")], | ||
| ]; | ||
| const label = Math.max(...rows.map(([k]) => k.length)); | ||
| const body = rows.map(([k, v]) => ` ${k.padEnd(label)} ${v}`); | ||
| const tail = [ | ||
| "", | ||
| " Denied by the grant, not by a prompt — the server never saw the call.", | ||
| ...(childId ? [` revoke everything: grantor-mcp revoke --child ${childId}`] : []), | ||
| ` what just happened: ${learnUrl}`, | ||
| ]; | ||
| const lines = [...body, ...tail]; | ||
| const width = Math.min(76, Math.max(...lines.map((l) => l.length), 28) + 2); | ||
| const clip = (l) => (l.length > width ? l.slice(0, width - 1) + "…" : l); | ||
| return [ | ||
| `┌─ DENIED ${"─".repeat(Math.max(1, width - 9))}┐`, | ||
| ...lines.map((l) => `│${clip(l).padEnd(width)}│`), | ||
| `└${"─".repeat(width)}┘`, | ||
| ].join("\n"); | ||
| } | ||
| export function toolDenyResponse(id, deny) { | ||
@@ -68,0 +99,0 @@ return { |
@@ -9,3 +9,3 @@ // proxy.js — the wrap relay: four streams, one enforcement seam. | ||
| stripGatedCapabilities, toolDenyResponse, resourceDenyResponse, | ||
| batchRefusalResponse, | ||
| batchRefusalResponse, denyArtifact, | ||
| } from "./protocol.js"; | ||
@@ -37,3 +37,9 @@ | ||
| enforce, grants, allowResources = false, verbose = false, log = () => {}, | ||
| childId, quiet = false, | ||
| }) { | ||
| // The boxed artifact rides the same stderr log seam as the DENY line it | ||
| // accompanies (never replaces — that line stays greppable/parseable). | ||
| const artifact = (fields) => { | ||
| if (!quiet) log(`\n${denyArtifact({ ...fields, grants, childId })}`); | ||
| }; | ||
| const pending = new Map(); // request id -> method, ONLY for forwarded REWRITE_METHODS | ||
@@ -74,2 +80,3 @@ const send = (stream, obj) => stream.write(`${JSON.stringify(obj)}\n`); | ||
| log(`DENY tools/call ${cls.tool}: ${code} (enforce threw)`); | ||
| artifact({ tool: cls.tool, decision: { code, reason } }); | ||
| if (msg.id !== undefined) send(clientOut, toolDenyResponse(msg.id, { allow: false, code, reason })); | ||
@@ -83,2 +90,3 @@ return; | ||
| log(`DENY tools/call ${cls.tool}: ${decision.code}`); | ||
| artifact({ tool: cls.tool, decision }); | ||
| // Answered locally; the child NEVER sees a denied request, so its | ||
@@ -85,0 +93,0 @@ // id cannot collide with anything we later forward. |
+2
-0
@@ -66,2 +66,4 @@ // run.js — the wrap runner: validate, (optionally) inline-grant, fail fast, | ||
| verbose: !!opts.verbose, | ||
| quiet: !!opts.quiet, | ||
| childId, | ||
| log, | ||
@@ -68,0 +70,0 @@ }); |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
113331
15.08%26
13.04%1656
22.21%280
8.11%12
20%4
100%