Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 33 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![npm version](https://img.shields.io/npm/v/toolfence.svg?style=flat-square&color=0B0B0D&labelColor=0B0B0D)](https://www.npmjs.com/package/toolfence)
[![CI](https://github.com/nikolas-sapa/toolfence/actions/workflows/ci.yml/badge.svg)](https://github.com/nikolas-sapa/toolfence/actions/workflows/ci.yml)
[![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-0B0B0D?style=flat-square&labelColor=0B0B0D)](./LICENSE)
[![Node](https://img.shields.io/badge/node-%3E%3D20-0B0B0D?style=flat-square&labelColor=0B0B0D)](./package.json)
[![Node](https://img.shields.io/badge/node-%3E%3D20.3-0B0B0D?style=flat-square&labelColor=0B0B0D)](./package.json)
[![PRs welcome](https://img.shields.io/badge/PRs-welcome-0B0B0D?style=flat-square&labelColor=0B0B0D)](./CONTRIBUTING.md)

**Security scanner for MCP servers.** Point it at any [Model Context Protocol](https://modelcontextprotocol.io) server and get a severity-ranked report of the risks your agents inherit by connecting to it.
Expand All @@ -22,7 +22,7 @@ npx toolfence https://your-server.example.com/mcp
Tool "fetch_doc" contains language matching: instruction override.
Tool descriptions are fed verbatim into the agent's context — this is
a tool-poisoning vector.
HIGH No authentication required
HIGH No credentials required for tool discovery
HIGH Tool definition changed since baseline [search]
MED Large tool catalog
...
Expand All @@ -35,7 +35,7 @@ Exit code is non-zero when high/critical findings exist, so it drops straight in

## Why

OAuth authenticated your agents. It didn't make them **safe**. The MCP spec settled authentication (OAuth 2.1 for HTTP transports) — but that's roughly 20% of the threat surface. The other 80% lives in what the tools *do* and what their definitions *say*:
Authentication alone does not establish tool safety. Tool definitions can expose additional risks:

- **Tool poisoning** — a malicious server ships a tool description that instructs the agent to read your secrets or ignore prior instructions. The agent reads it and complies.
- **Indirect prompt injection** — adversarial text smuggled into tool definitions or schemas.
Expand Down Expand Up @@ -71,12 +71,19 @@ toolfence https://example.com/mcp --bearer "$TOKEN"

# Local stdio server (everything after --stdio is the command)
toolfence --stdio npx -y @modelcontextprotocol/server-everything
toolfence --json --no-baseline --stdio node server.js "argument with spaces"

# Machine-readable output for CI / dashboards
toolfence https://example.com/mcp --json
toolfence https://example.com/mcp --markdown -o report.md
```

Requires Node 20.3 or newer for [combined abort signals](https://nodejs.org/download/release/v20.20.1/docs/api/globals.html#static-method-abortsignalanysignals).
Place scanner options before `--stdio`. Every subsequent argument goes unchanged
to the child server, including `--help`. Connection, handshake and catalog paging
share a 30-second deadline; the initial HTTP header probe is capped at 5 seconds.
Stdio commands run with your user permissions; the scanner does not sandbox them.

### Options

| Flag | Description |
Expand All @@ -95,7 +102,7 @@ toolfence https://example.com/mcp --markdown -o report.md
|------|---------|
| `0` | No high/critical findings |
| `1` | At least one high or critical finding |
| `2` | Scan could not run (connection / usage error) |
| `2` | Scan incomplete (connection, usage, baseline or check error) |

## What it checks (12)

Expand All @@ -107,7 +114,7 @@ toolfence https://example.com/mcp --markdown -o report.md
| **Known-bad signatures** | Documented MCP abuse patterns (secret-file reads, rug-pulls, cross-tool shadowing, exfiltration-to-external, obfuscated payloads) |
| **Tool integrity / drift** | Tool definitions that changed since the last scan |
| **Context cost** | Tool catalogs large enough to inflate every agent turn |
| **Rate-limit posture** | No server-side ceiling on call volume |
| **Rate-limit posture** | Missing rate-limit headers on the initial HTTP probe |
| **Naming hygiene** | Duplicate or collision-prone generic tool names |
| **Sensitive capability** | Tools that reach the filesystem, execute code, or touch the network |
| **Schema strength** | Missing, untyped, or unsealed input schemas |
Expand All @@ -116,13 +123,29 @@ toolfence https://example.com/mcp --markdown -o report.md

The [known-bad signature set](./src/signatures.ts) is community-extensible — see [CONTRIBUTING](./CONTRIBUTING.md).

Checks inspect server metadata and tool definitions; they do not invoke tools.
Anonymous catalog discovery does not prove anonymous tool execution. Missing
rate-limit headers do not prove absent enforcement. Findings are heuristics,
not a guarantee that a server is safe or unsafe.

### Drift detection

The first scan of a target records a fingerprint of every tool definition under
`~/.toolfence/baselines/`. Subsequent scans of the same target compare against it
and flag any tool whose definition changed, appeared, or disappeared — the core
signal for catching a server that turns malicious *after* you trusted it. Run it
in CI to fail the build on unexpected drift.
Each completed scan records tool fingerprints under `~/.toolfence/baselines/`.
The next scan compares names, descriptions, input schemas and safety annotations
against that latest observation. Changes, additions and removals produce findings.
Completed scans replace the baseline even when high/critical findings exist;
this is not an immutable record of approved tools. Incomplete discovery or failed
checks exit 2 without replacing it. Malformed or unreadable baselines fail the scan.

Writes use a private temporary file and atomic replacement. Reports and baselines
retain the target URL or command arguments: avoid embedding credentials in them.
`--bearer` avoids storing the token in the target, but command-line arguments may
still be visible to local process inspection. Use `--no-baseline` to skip caching.

Upgrading from earlier versions can flag one-time drift for annotated tools.
Stdio targets now use JSON-quoted argument tokens as their cache identity, so
their first scan establishes a new baseline. Annotation-free HTTP fingerprints
retain their previous format.

## Roadmap

Expand Down
76 changes: 45 additions & 31 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"build": "tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json --watch",
"start": "node dist/index.js",
"test": "npm run build && node --test test/checks.test.mjs",
"test": "npm run build && node --test test/*.test.mjs",
"prepublishOnly": "npm run build"
},
"keywords": [
Expand All @@ -29,7 +29,7 @@
],
"license": "Apache-2.0",
"engines": {
"node": ">=20"
"node": ">=20.3"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
Expand Down
59 changes: 43 additions & 16 deletions src/baseline.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
// Baseline persistence for drift detection. Stored per-target under
// ~/.toolfence/baselines/<hash>.json so re-running the same target compares
// against the last seen tool fingerprints.

import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
// Baselines record the latest completed scan, not an immutable trust decision.
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, open, rename, rm } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Baseline, ToolInfo } from "./types.js";
Expand All @@ -13,33 +10,63 @@ function baselineDir(): string {
return join(homedir(), ".toolfence", "baselines");
}

function baselinePath(target: string): string {
function baselinePath(target: string, directory: string): string {
const h = createHash("sha256").update(target).digest("hex").slice(0, 24);
return join(baselineDir(), `${h}.json`);
return join(directory, `${h}.json`);
}

export async function loadBaseline(
target: string,
directory = baselineDir(),
): Promise<Baseline | undefined> {
let raw: string;
try {
raw = await readFile(baselinePath(target, directory), "utf8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw err;
}
let parsed: unknown;
try {
const raw = await readFile(baselinePath(target), "utf8");
return JSON.parse(raw) as Baseline;
parsed = JSON.parse(raw);
} catch {
return undefined;
throw new Error("Invalid drift baseline JSON; repair it before scanning.");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Invalid drift baseline structure.");
}
const b = parsed as Partial<Baseline>;
if (typeof b.target !== "string" || typeof b.createdAt !== "string" ||
!b.toolFingerprints || typeof b.toolFingerprints !== "object" ||
Array.isArray(b.toolFingerprints) ||
!Object.values(b.toolFingerprints).every(hash => typeof hash === "string")) {
throw new Error("Invalid drift baseline structure.");
}
return b as Baseline;
}

export async function saveBaseline(
target: string,
tools: ToolInfo[],
directory = baselineDir(),
): Promise<void> {
const baseline: Baseline = {
createdAt: new Date().toISOString(),
target,
toolFingerprints: Object.fromEntries(
tools.map((t) => [t.name, fingerprint(t)]),
),
toolFingerprints: Object.fromEntries(tools.map(t => [t.name, fingerprint(t)])),
};
await mkdir(baselineDir(), { recursive: true });
await writeFile(baselinePath(target), JSON.stringify(baseline, null, 2));
await mkdir(directory, { recursive: true, mode: 0o700 });
const file = baselinePath(target, directory);
const tmp = `${file}.${randomUUID()}.tmp`;
const handle = await open(tmp, "wx", 0o600);
try {
try {
await handle.writeFile(JSON.stringify(baseline, null, 2));
} finally {
await handle.close();
}
await rename(tmp, file);
} finally {
await rm(tmp, { force: true });
}
}
Loading
Loading