Overview
WebMCP is a browser standard that lets a website expose actions to AI agents. A page registers tools on document.modelContext, each with a name, a description, an input schema and a handler, and an agent working in the browser can call them for the user.
That is powerful and easy to get wrong. A tool that deletes data with no confirmation, a form that submits itself, or a handler that returns a password hash are all one agent call away from a bad day.
agentfrisk is a security linter for those tools. It reads your source code, finds every WebMCP tool your site exposes, works out whether each one reads, writes or destroys data, and reports the dangerous ones with a fix. It works from the command line and as a CI check.
IT NEVER RUNS YOUR CODE
agentfrisk is pure static analysis built on the TypeScript compiler API. It parses JavaScript, TypeScript, JSX, TSX and HTML files and follows imports, but it never executes anything.
Quick start
-
Check your Node version
agentfrisk needs Node.js 20.19 or later.
node --version -
Run a scan
From your project root. No install or config needed.
npx agentfrisk scanTo scan a subfolder, pass it as an argument:
npx agentfrisk scan web/. To see only the tools without findings, usenpx agentfrisk list. -
Install it for your team (optional)
Pin the version in your project so everyone and CI run the same rules.
npm install --save-dev agentfriskThen add a script to
package.json:{ "scripts": { "lint:webmcp": "agentfrisk scan" } } -
Fix what it finds
Every finding comes with a fix. Apply it, re-run the scan, and repeat until the exit code is 0. If a finding is intentional, suppress it on that tool.
-
Add it to CI
See CI and SARIF for a ready-to-paste GitHub Actions workflow.
Reading the report
scan prints one block per file. Here is a real report from a small project:
src/tools.ts
get_order_status read imperative, line 5
✓ no findings
delete_account destructive imperative, line 23
error destructive-no-confirm Destructive tool "delete_account" runs without asking the user to confirm.
fix: Set annotations: { consequentialHint: true } so the browser asks the user before it runs, ...
2 tools, 1 error, 0 warnings, 0 info- File heading
- Paths are relative to the folder you ran the command in, which is what GitHub needs to place annotations.
- Tool line
- The tool name, its effect (read, write or destructive), how it was defined (imperative, library or declarative) and the line it is defined on.
- Finding
- Severity, rule id and a sentence explaining the risk. Look up the rule id in Rules for examples.
- fix:
- What to change to make the finding go away.
- Summary
- Totals for the whole scan. The process exits with 1 if any finding is at or above
--fail-on(defaulterror), otherwise 0.
Commands and flags
npx agentfrisk scan [dir]
npx agentfrisk list [dir]| Command | What it does |
|---|---|
scan [dir] | Finds every tool under dir (default .), runs all rules and prints a report grouped by file. |
list [dir] | Prints only the discovered tools and their effects. Always exits 0. |
| Flag | Description |
|---|---|
--json | Machine-readable JSON instead of the text report. Works with both commands. |
--sarif | SARIF 2.1.0 for GitHub code scanning. scan only; cannot be combined with --json. |
--fail-on <level> | error, warning or info. The lowest severity that makes scan exit 1. Default error. |
--config <path> | Use this config file instead of <dir>/agentfrisk.config.json. |
-h, --help | Show help. |
-v, --version | Show the version. |
Exit codes
| Code | Meaning |
|---|---|
0 | No findings at or above --fail-on, or the command was list. |
1 | At least one finding at or above --fail-on. |
2 | Usage error, invalid config, or the folder does not exist. |
Configuration
Configuration is optional. To change it, create agentfrisk.config.json in the folder you scan:
{
"rules": {
"loose-schema": "error",
"deprecated-api": "off"
},
"include": ["src/**/*.{ts,tsx}", "public/**/*.html"],
"exclude": ["src/legacy/**", "**/*.stories.tsx"]
}| Key | Description |
|---|---|
rules | Map of rule id to "error", "warning", "info" or "off". Rules you leave out keep their default severity. |
include | Replaces the default globs: **/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,html,htm}. |
exclude | Added to the default excludes: node_modules, .git, dist, build, out, .next, coverage, **/*.d.ts. A pattern without a / matches a file or folder name at any depth. |
If your project has a tsconfig.json, its paths aliases (such as @/) are used when following imports.
Suppressing findings
When a finding is intentional, put agentfrisk-ignore in a comment on the line directly above the tool: the registerTool( call, the defineTools key, the useMcpTool( call or the <form tag. List one or more rule ids, separated by spaces or commas. With no rule id, every rule is silenced for that tool.
// agentfrisk-ignore loose-schema
mc.registerTool({ name: "get_feature_flags", /* ... */ });{/* agentfrisk-ignore loose-schema, missing-description */}
<form toolname="leave_note" tooldescription="Leave a note"><!-- agentfrisk-ignore autosubmit-write -->
<form toolname="quick_add" tooldescription="Add an item to the shopping list" toolautosubmit>To turn a rule off for the whole project instead, set it to "off" in the config.
CI and SARIF
This GitHub Actions workflow uploads findings to code scanning, where they appear as annotations on the exact lines of a pull request, and fails the job when an error is found.
name: agentfrisk
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
security-events: write
jobs:
webmcp-tools:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
- name: Scan WebMCP tools
run: npx --yes agentfrisk@0.1 scan . --sarif > agentfrisk.sarif
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: agentfrisk.sarif
category: agentfrisk- To fail on warnings too, add
--fail-on warning. - To report without failing the build, add
continue-on-error: trueto the scan step. - The SARIF output validates against the official SARIF 2.1.0 schema and includes rule metadata and a security severity for every finding.
- Not on GitHub? Use
--jsonor the plain text report and rely on the exit code.
JSON output
scan --json prints one object:
{
"version": "0.1.0",
"summary": { "tools": 2, "error": 1, "warning": 0, "info": 0 },
"tools": [
{
"name": "delete_account",
"description": "Permanently delete the signed-in user's account.",
"effect": "destructive",
"style": "imperative",
"source": "src/tools.ts:23",
"file": "src/tools.ts",
"line": 23,
"confirms": false,
"autosubmit": false,
"inputSchema": { "type": "object", "properties": {}, "additionalProperties": false },
"signals": ["db.users.delete()"],
"findings": [
{ "ruleId": "destructive-no-confirm", "severity": "error", "message": "...", "fix": "..." }
]
}
],
"findings": [ /* every finding, flattened, with tool, file, line and source */ ]
}list --json prints { "version", "tools" } with the same tool fields and no findings. signals lists the handler evidence behind the effect, such as fetch DELETE request or SQL DELETE statement.
Programmatic API
Use agentfrisk from a script or another tool:
import { scan } from "agentfrisk";
const result = scan("./src");
for (const { tool, findings } of result.tools) {
console.log(tool.name, tool.classification.effect, findings.map((f) => f.ruleId));
}formatJson, formatSarif, RULES and runRules are exported too. Each rule is an object { id, severity, description, fix, check(tool) }, where check returns a message or null, so adding a rule means adding one file.
What it detects
agentfrisk understands the three ways a site can define WebMCP tools.
Imperative
Calls to registerTool on document.modelContext or the older navigator.modelContext, including window. and globalThis. prefixes, optional chaining, aliases, destructuring and the legacy provideContext({ tools }). Tool objects and schemas declared elsewhere or imported from other files are resolved.
const mc = document.modelContext ?? navigator.modelContext;
mc.registerTool({
name: "get_order_status",
description: "Get the shipping status of one of the user's orders.",
inputSchema: {
type: "object",
properties: { orderId: { type: "string", pattern: "^ord_[a-z0-9]{12}$" } },
required: ["orderId"],
additionalProperties: false,
},
annotations: { readOnlyHint: true },
execute: async ({ orderId }) => (await fetch(`/api/orders/${orderId}`)).text(),
});Libraries
defineTools and tool() from nextjs-webmcp, and useMcpTool from webmcp-react. Calls are matched by where they are imported from, so renamed imports work. Zod schemas are converted to JSON Schema the way z.toJSONSchema would, and server actions passed as execute are followed.
import { defineTools, tool } from "nextjs-webmcp";
import { addTodo } from "./actions";
export const tools = defineTools({
add_todo: tool({
description: "Add a todo to the signed-in user's list.",
input: z.object({ text: z.string().min(1).max(200) }),
execute: addTodo,
}),
});Declarative forms
Forms with toolname in JSX, TSX and plain HTML, including toolautosubmit and the fields' name, type, maxlength, pattern, required, toolparamdescription and <select> options. Imperative tools inside inline <script> blocks of HTML files are found too.
<form toolname="search_flights"
tooldescription="Search available flights between two airports on a date."
toolautosubmit action="/search">
<input name="from" pattern="[A-Z]{3}" required>
<input name="to" pattern="[A-Z]{3}" required>
<input name="date" type="date" required>
</form>How tools are classified
Every tool gets one effect: read, write or destructive. Two kinds of evidence are used, and the handler body wins when they disagree.
1. What the handler does
The handler is analyzed along with every function it calls, one level deep and across imports.
| Signal | Write | Destructive |
|---|---|---|
| Database calls | .update .insert .create .upsert .save | .delete .destroy .remove .del .drop .truncate |
| Raw SQL | UPDATE … SET, INSERT INTO | DELETE FROM, DROP TABLE, TRUNCATE |
| HTTP (fetch, axios) | POST PUT PATCH | DELETE |
| Payments (Stripe) | none | charges refunds transfers payouts paymentIntents |
Method names that start with those verbs count too, so deleteMany is destructive and createUser is a write.
2. What the name and description say
Used when the handler gives no signal. delete, remove, cancel, refund, pay, charge, transfer, purge, drop, reset mean destructive; create, add, update, save, send, submit mean write; get, list, search, find, view mean read. A tool with no signal at all counts as read.
Confirmation
WebMCP has no single "ask the user" call, so any of these counts as confirming:
annotations: { consequentialHint: true }, the current spec's signal for actions that need confirmation.client.requestUserInteraction(...)in the handler, from earlier drafts of the spec.confirm(...)orwindow.confirm(...)in the handler.- A declarative form without
toolautosubmit: the agent fills it in and the user clicks submit.
Rules
Seven rules, each with a default severity you can change in the config.
| Rule | Default | Catches |
|---|
FAQ and limitations
- Does it run my code?
- No. It only parses source files. Nothing is executed, so it is safe to run on untrusted branches.
- Which files does it read?
- JavaScript, TypeScript, JSX, TSX and HTML. Vue and Svelte single-file components are not supported in v0.1.
- A tool is missing from the report.
- Tools registered from fully dynamic data, such as
tools.forEach((t) => mc.registerTool(t))over a runtime array, cannot be found statically. Define tools with literal objects or named constants. - A tool has the wrong effect.
- Effect detection is heuristic, and calls are followed only one level deep from the handler. Rename the function to say what it does, or suppress the finding on that tool. Please report false positives.
- Can I write my own rule?
- Yes. A rule is one file exporting
{ id, severity, description, fix, check(tool) }. Add it to the rule list and it runs everywhere, including SARIF output.