DOCUMENTATION · v0.1

Everything you need to run a scan

From your first npx agentfrisk scan to a CI check that annotates pull requests. Each rule is explained with code that gets flagged and code that passes.

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

  1. Check your Node version

    agentfrisk needs Node.js 20.19 or later.

    bash
    node --version
  2. Run a scan

    From your project root. No install or config needed.

    bash
    npx agentfrisk scan

    To scan a subfolder, pass it as an argument: npx agentfrisk scan web/. To see only the tools without findings, use npx agentfrisk list.

  3. Install it for your team (optional)

    Pin the version in your project so everyone and CI run the same rules.

    bash
    npm install --save-dev agentfrisk

    Then add a script to package.json:

    package.json
    {
      "scripts": {
        "lint:webmcp": "agentfrisk scan"
      }
    }
  4. 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.

  5. 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:

terminal
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 (default error), otherwise 0.

Commands and flags

bash
npx agentfrisk scan [dir]
npx agentfrisk list [dir]
CommandWhat 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.
FlagDescription
--jsonMachine-readable JSON instead of the text report. Works with both commands.
--sarifSARIF 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, --helpShow help.
-v, --versionShow the version.

Exit codes

CodeMeaning
0No findings at or above --fail-on, or the command was list.
1At least one finding at or above --fail-on.
2Usage 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:

agentfrisk.config.json
{
  "rules": {
    "loose-schema": "error",
    "deprecated-api": "off"
  },
  "include": ["src/**/*.{ts,tsx}", "public/**/*.html"],
  "exclude": ["src/legacy/**", "**/*.stories.tsx"]
}
KeyDescription
rulesMap of rule id to "error", "warning", "info" or "off". Rules you leave out keep their default severity.
includeReplaces the default globs: **/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,html,htm}.
excludeAdded 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.

tools.ts
// agentfrisk-ignore loose-schema
mc.registerTool({ name: "get_feature_flags", /* ... */ });
Notes.tsx
{/* agentfrisk-ignore loose-schema, missing-description */}
<form toolname="leave_note" tooldescription="Leave a note">
index.html
<!-- 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.

.github/workflows/agentfrisk.yml
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: true to 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 --json or the plain text report and rely on the exit code.

JSON output

scan --json prints one object:

json
{
  "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:

scan.mjs
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.

tools.ts
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.

tools.tsx
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.

index.html
<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.

SignalWriteDestructive
Database calls.update .insert .create .upsert .save.delete .destroy .remove .del .drop .truncate
Raw SQLUPDATE … SET, INSERT INTODELETE FROM, DROP TABLE, TRUNCATE
HTTP (fetch, axios)POST PUT PATCHDELETE
Payments (Stripe)nonecharges 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(...) or window.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.

RuleDefaultCatches

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.