@graneth/mcp-server
Advanced tools
+219
-63
| #!/usr/bin/env node | ||
| // src/index.ts | ||
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
| import { z } from "zod"; | ||
| import { createInterface } from "readline"; | ||
| // src/protocol.ts | ||
| var LATEST_PROTOCOL_VERSION = "2025-11-25"; | ||
| var SUPPORTED_PROTOCOL_VERSIONS = [ | ||
| LATEST_PROTOCOL_VERSION, | ||
| "2025-06-18", | ||
| "2025-03-26", | ||
| "2024-11-05", | ||
| "2024-10-07" | ||
| ]; | ||
| var ErrorCode = { | ||
| ParseError: -32700, | ||
| InvalidRequest: -32600, | ||
| MethodNotFound: -32601, | ||
| InvalidParams: -32602, | ||
| InternalError: -32603 | ||
| }; | ||
| var InvalidParams = class extends Error { | ||
| }; | ||
| function ok(id, result) { | ||
| return { jsonrpc: "2.0", id, result }; | ||
| } | ||
| function fail(id, code, message) { | ||
| return { jsonrpc: "2.0", id, error: { code, message } }; | ||
| } | ||
| function createServer(opts) { | ||
| const byName = new Map(opts.tools.map((t) => [t.name, t])); | ||
| async function callTool(id, params) { | ||
| const name = params?.name; | ||
| const tool = typeof name === "string" ? byName.get(name) : void 0; | ||
| if (!tool) { | ||
| return fail(id, ErrorCode.InvalidParams, `Unknown tool: ${String(name)}`); | ||
| } | ||
| let input; | ||
| try { | ||
| input = tool.parse(params?.arguments); | ||
| } catch (err) { | ||
| const message = err instanceof InvalidParams ? err.message : `Invalid arguments: ${String(err)}`; | ||
| return fail(id, ErrorCode.InvalidParams, message); | ||
| } | ||
| try { | ||
| const result = await tool.handler(input); | ||
| return ok(id, result); | ||
| } catch (err) { | ||
| return fail(id, ErrorCode.InternalError, `Tool "${tool.name}" failed: ${String(err)}`); | ||
| } | ||
| } | ||
| async function handleMessage(msg) { | ||
| const req = msg ?? {}; | ||
| const id = req.id ?? null; | ||
| const method = req.method; | ||
| if (typeof method !== "string") { | ||
| return fail(id, ErrorCode.InvalidRequest, "Missing or invalid `method`"); | ||
| } | ||
| if (method.startsWith("notifications/")) return null; | ||
| switch (method) { | ||
| case "initialize": { | ||
| const requested = req.params?.protocolVersion; | ||
| const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : LATEST_PROTOCOL_VERSION; | ||
| return ok(id, { | ||
| protocolVersion, | ||
| capabilities: { tools: {} }, | ||
| serverInfo: { name: opts.name, version: opts.version } | ||
| }); | ||
| } | ||
| case "ping": | ||
| return ok(id, {}); | ||
| case "tools/list": | ||
| return ok(id, { | ||
| tools: opts.tools.map((t) => ({ | ||
| name: t.name, | ||
| description: t.description, | ||
| inputSchema: t.inputSchema | ||
| })) | ||
| }); | ||
| case "tools/call": | ||
| return callTool(id, req.params); | ||
| default: | ||
| return fail(id, ErrorCode.MethodNotFound, `Method not found: ${method}`); | ||
| } | ||
| } | ||
| return { handleMessage }; | ||
| } | ||
| // ../core-checks/src/secrets.ts | ||
@@ -1216,13 +1297,58 @@ function shannonEntropy(str) { | ||
| // src/index.ts | ||
| var VERSION = "0.6.0"; | ||
| var VERSION = "0.7.0"; | ||
| function requireObject(args) { | ||
| if (!args || typeof args !== "object" || Array.isArray(args)) { | ||
| throw new InvalidParams("arguments must be an object"); | ||
| } | ||
| return args; | ||
| } | ||
| function requireString(v, field, min, max) { | ||
| if (typeof v !== "string") throw new InvalidParams(`\`${field}\` must be a string`); | ||
| if (v.length < min) throw new InvalidParams(`\`${field}\` must be at least ${min} character(s)`); | ||
| if (v.length > max) throw new InvalidParams(`\`${field}\` must be at most ${max} characters`); | ||
| return v; | ||
| } | ||
| function optionalString(v, field, min, max) { | ||
| if (v === void 0 || v === null) return void 0; | ||
| return requireString(v, field, min, max); | ||
| } | ||
| var PRE_FLIGHT_CHECK_DESCRIPTION = "Security pre-flight check for local file changes BEFORE committing. Run this whenever you are about to suggest `git commit`, `git push`, or open a pull request \u2014 especially when changes add package imports or dependencies (package.json / requirements.txt / Cargo.toml / go.mod / Gemfile / composer.json / import statements) or could contain secrets. Detects AI-hallucinated (non-existent) packages by live-checking six registries (npm, PyPI, crates.io, RubyGems, Go module proxy, Packagist) plus a bundled public threat-feed snapshot, which keeps catching a known hallucinated name even after an attacker registers it; risk-scores dependencies an AI agent introduced by compounding metadata signals (new + install-scripts + low-adoption + no-provenance = an attack shape no single check flags); and finds hardcoded credentials via pattern + entropy analysis. Always free, no account required. Returns CLEAR, REVIEW_REQUIRED, or BLOCKED with specific findings and remediation."; | ||
| var PRE_FLIGHT_CHECK_SCHEMA = { | ||
| files: z.array( | ||
| z.object({ | ||
| path: z.string().min(1).max(1024).describe("Relative file path"), | ||
| content: z.string().max(2e5).describe("Full file content to check") | ||
| }) | ||
| ).min(1).max(50).describe("Files staged for commit (path + content)"), | ||
| context: z.string().max(500).optional().describe("What these changes do (helps triage)") | ||
| var PRE_FLIGHT_CHECK_INPUT_SCHEMA = { | ||
| type: "object", | ||
| properties: { | ||
| files: { | ||
| type: "array", | ||
| minItems: 1, | ||
| maxItems: 50, | ||
| description: "Files staged for commit (path + content)", | ||
| items: { | ||
| type: "object", | ||
| properties: { | ||
| path: { type: "string", minLength: 1, maxLength: 1024, description: "Relative file path" }, | ||
| content: { type: "string", maxLength: 2e5, description: "Full file content to check" } | ||
| }, | ||
| required: ["path", "content"], | ||
| additionalProperties: false | ||
| } | ||
| }, | ||
| context: { type: "string", maxLength: 500, description: "What these changes do (helps triage)" } | ||
| }, | ||
| required: ["files"], | ||
| additionalProperties: false | ||
| }; | ||
| function parsePreFlight(args) { | ||
| const o = requireObject(args); | ||
| if (!Array.isArray(o.files)) throw new InvalidParams("`files` must be an array"); | ||
| if (o.files.length < 1) throw new InvalidParams("`files` must contain at least 1 file"); | ||
| if (o.files.length > 50) throw new InvalidParams("`files` must contain at most 50 files"); | ||
| const files = o.files.map((raw, i) => { | ||
| if (!raw || typeof raw !== "object") throw new InvalidParams(`files[${i}] must be an object`); | ||
| const f = raw; | ||
| return { | ||
| path: requireString(f.path, `files[${i}].path`, 1, 1024), | ||
| content: requireString(f.content, `files[${i}].content`, 0, 2e5) | ||
| }; | ||
| }); | ||
| return { files, context: optionalString(o.context, "context", 0, 500) }; | ||
| } | ||
| var VERDICT_ACTION = { | ||
@@ -1262,20 +1388,44 @@ BLOCKED: "Do NOT commit these changes. Fix all CRITICAL findings before proceeding. Use each finding's `recommendation` for remediation.", | ||
| } | ||
| async function handlePreFlightCheckTool({ files, context }) { | ||
| try { | ||
| const result = await runPreFlightCheck(files, context); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| } catch (err) { | ||
| return { | ||
| content: [{ type: "text", text: `Pre-flight check failed: ${String(err)}` }], | ||
| isError: true | ||
| }; | ||
| var preFlightCheckTool = { | ||
| name: "pre_flight_check", | ||
| description: PRE_FLIGHT_CHECK_DESCRIPTION, | ||
| inputSchema: PRE_FLIGHT_CHECK_INPUT_SCHEMA, | ||
| parse: parsePreFlight, | ||
| async handler({ files, context }) { | ||
| try { | ||
| const result = await runPreFlightCheck(files, context); | ||
| return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; | ||
| } catch (err) { | ||
| return { | ||
| content: [{ type: "text", text: `Pre-flight check failed: ${String(err)}` }], | ||
| isError: true | ||
| }; | ||
| } | ||
| } | ||
| } | ||
| }; | ||
| var REPORT_URL = "https://graneth.com/api/threat-feed/report"; | ||
| var REPORTABLE_ECOSYSTEMS = ["npm", "pypi", "crates", "gems"]; | ||
| var REPORT_HALLUCINATION_DESCRIPTION = "Report an AI-hallucinated package name to Graneth's public threat feed, so every user's pre-flight catches it \u2014 permanently, even if an attacker registers the name later. IMPORTANT: only call this AFTER the human has explicitly agreed to report the name; ask them first. Sends exactly one package name + ecosystem to graneth.com (never file contents). The server re-verifies non-existence against the live registry before accepting. Reportable ecosystems: npm (unscoped), pypi, crates, gems \u2014 namespaced names (npm @scope, go, composer) are rejected to keep internal package names out of a public feed. Optional `reporter` is a public attribution handle shown on the feed entry."; | ||
| var REPORT_HALLUCINATION_SCHEMA = { | ||
| package: z.string().min(1).max(214).describe("The hallucinated package name (as caught by pre_flight_check)"), | ||
| ecosystem: z.enum(["npm", "pypi", "crates", "gems"]).describe("Registry the name was checked against"), | ||
| reporter: z.string().min(2).max(30).optional().describe("Optional public attribution handle ([a-zA-Z0-9_-])") | ||
| var REPORT_HALLUCINATION_INPUT_SCHEMA = { | ||
| type: "object", | ||
| properties: { | ||
| package: { type: "string", minLength: 1, maxLength: 214, description: "The hallucinated package name (as caught by pre_flight_check)" }, | ||
| ecosystem: { type: "string", enum: [...REPORTABLE_ECOSYSTEMS], description: "Registry the name was checked against" }, | ||
| reporter: { type: "string", minLength: 2, maxLength: 30, description: "Optional public attribution handle ([a-zA-Z0-9_-])" } | ||
| }, | ||
| required: ["package", "ecosystem"], | ||
| additionalProperties: false | ||
| }; | ||
| function parseReport(args) { | ||
| const o = requireObject(args); | ||
| const eco = o.ecosystem; | ||
| if (typeof eco !== "string" || !REPORTABLE_ECOSYSTEMS.includes(eco)) { | ||
| throw new InvalidParams(`\`ecosystem\` must be one of: ${REPORTABLE_ECOSYSTEMS.join(", ")}`); | ||
| } | ||
| return { | ||
| package: requireString(o.package, "package", 1, 214), | ||
| ecosystem: eco, | ||
| reporter: optionalString(o.reporter, "reporter", 2, 30) | ||
| }; | ||
| } | ||
| var REPORT_OUTCOME_TEXT = { | ||
@@ -1287,43 +1437,49 @@ accepted: (b) => `Reported. "${b.entry?.name}" is in the public threat feed now (community tier)${b.entry?.reporter ? `, attributed to "${b.entry.reporter}"` : ""}. It stays caught for every Graneth user even if someone registers the name later. Feed: https://graneth.com/api/threat-feed`, | ||
| }; | ||
| async function handleReportHallucinationTool({ package: pkg, ecosystem, reporter }) { | ||
| try { | ||
| const res = await fetch(REPORT_URL, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ package: pkg, ecosystem, reporter }), | ||
| signal: AbortSignal.timeout(15e3) | ||
| }); | ||
| const body = await res.json().catch(() => ({})); | ||
| const render = REPORT_OUTCOME_TEXT[body.status]; | ||
| const text = render ? render(body) : `Unexpected response (HTTP ${res.status}) \u2014 nothing was stored.`; | ||
| return { content: [{ type: "text", text }] }; | ||
| } catch (err) { | ||
| return { | ||
| content: [{ type: "text", text: `Report failed before reaching the feed: ${String(err)}. Nothing was stored.` }], | ||
| isError: true | ||
| }; | ||
| var reportHallucinationTool = { | ||
| name: "report_hallucination", | ||
| description: REPORT_HALLUCINATION_DESCRIPTION, | ||
| inputSchema: REPORT_HALLUCINATION_INPUT_SCHEMA, | ||
| parse: parseReport, | ||
| async handler({ package: pkg, ecosystem, reporter }) { | ||
| try { | ||
| const res = await fetch(REPORT_URL, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ package: pkg, ecosystem, reporter }), | ||
| signal: AbortSignal.timeout(15e3) | ||
| }); | ||
| const body = await res.json().catch(() => ({})); | ||
| const render = REPORT_OUTCOME_TEXT[body.status]; | ||
| const text = render ? render(body) : `Unexpected response (HTTP ${res.status}) \u2014 nothing was stored.`; | ||
| return { content: [{ type: "text", text }] }; | ||
| } catch (err) { | ||
| return { | ||
| content: [{ type: "text", text: `Report failed before reaching the feed: ${String(err)}. Nothing was stored.` }], | ||
| isError: true | ||
| }; | ||
| } | ||
| } | ||
| } | ||
| function buildServer() { | ||
| const mcp = new McpServer({ name: "graneth", version: VERSION }); | ||
| mcp.tool( | ||
| "pre_flight_check", | ||
| PRE_FLIGHT_CHECK_DESCRIPTION, | ||
| PRE_FLIGHT_CHECK_SCHEMA, | ||
| handlePreFlightCheckTool | ||
| ); | ||
| mcp.tool( | ||
| "report_hallucination", | ||
| REPORT_HALLUCINATION_DESCRIPTION, | ||
| REPORT_HALLUCINATION_SCHEMA, | ||
| handleReportHallucinationTool | ||
| ); | ||
| return mcp; | ||
| } | ||
| }; | ||
| async function main() { | ||
| const server = buildServer(); | ||
| const transport = new StdioServerTransport(); | ||
| await server.connect(transport); | ||
| const server = createServer({ | ||
| name: "graneth", | ||
| version: VERSION, | ||
| tools: [preFlightCheckTool, reportHallucinationTool] | ||
| }); | ||
| const rl = createInterface({ input: process.stdin, crlfDelay: Infinity }); | ||
| process.stderr.write(`[graneth-mcp-server] v${VERSION} ready on stdio (pre_flight_check, report_hallucination) | ||
| `); | ||
| for await (const line of rl) { | ||
| const trimmed = line.trim(); | ||
| if (!trimmed) continue; | ||
| let msg; | ||
| try { | ||
| msg = JSON.parse(trimmed); | ||
| } catch { | ||
| process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: ErrorCode.ParseError, message: "Parse error" } }) + "\n"); | ||
| continue; | ||
| } | ||
| const response = await server.handleMessage(msg); | ||
| if (response !== null) process.stdout.write(JSON.stringify(response) + "\n"); | ||
| } | ||
| } | ||
@@ -1330,0 +1486,0 @@ main().catch((err) => { |
+1
-5
| { | ||
| "name": "@graneth/mcp-server", | ||
| "version": "0.6.0", | ||
| "version": "0.7.0", | ||
| "mcpName": "com.graneth/mcp-server", | ||
@@ -31,6 +31,2 @@ "description": "Account-free MCP server: catch AI-hallucinated packages (npm, PyPI, crates.io, RubyGems, Go, Packagist), risk-score the dependencies an AI agent introduces, and find hardcoded secrets before you commit. Exposes the free pre_flight_check tool over stdio.", | ||
| "license": "MIT", | ||
| "dependencies": { | ||
| "@modelcontextprotocol/sdk": "^1.29.0", | ||
| "zod": "^4.4.3" | ||
| }, | ||
| "devDependencies": { | ||
@@ -37,0 +33,0 @@ "tsup": "^8.5.1" |
+4
-0
@@ -7,2 +7,6 @@ # @graneth/mcp-server | ||
| **Zero runtime dependencies** (verify: `npm view @graneth/mcp-server dependencies`). A | ||
| supply-chain security tool should be auditable in full — so the MCP/JSON-RPC layer is a | ||
| small hand-rolled core, not a framework, and `npm audit` on a fresh install is clean. | ||
| It exposes a single, always-free tool over stdio: | ||
@@ -9,0 +13,0 @@ |
67692
8.88%0
-100%1486
11.65%147
2.8%- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed