MCP and skills
On this page
npx -y @vulsight/guard-mcp runs the server with nothing to install first. The same checks run through the API for a client without MCP.
What it is
An MCP server that gives a coding agent in Claude Code, Codex, or any other MCP client five tools. The one that matters is check_payment: ask the guard before paying, stop on a deny, and quote the reasons to the person.
check_paymenttakes the payment as the seller's 402 states it (withobserved402PayTo, the payee the seller's own 402 named, so a redirected payee is denied), or the contract call about to be signed, the text of the page that led to it, and how long to wait for a reviewer. It answers with the verdict, the reasons, and the decision id. An optionalidempotency_key, one per payment attempt, lets a call that timed out be sent again and decided once.file_contextfiles text the agent read that did not go throughcheck_payment: a page, a file, a search result, another tool's output. It answers flagged, clear, or unchecked in a sentence or two; unchecked is the state the rest of these pages call unavailable.report_settlementrecords a payment the guard allowed once it settles.get_policysays what the guard allows before the agent tries.list_recent_decisionssays what it decided recently.
One command, the skill, and in Claude Code an optional hook
One command, plus the skill that tells the agent when to call the tool. Without the skill the agent has the tool and no habit of using it.
claude mcp add vulsight-guard \
-e VULSIGHT_API_KEY=$VULSIGHT_API_KEY \
-e VULSIGHT_BASE_URL=https:// vulsight-guard.vercel.app \
-- npx -y @vulsight/ guard-mcpVULSIGHT_API_KEY is required and VULSIGHT_BASE_URL defaults to the address above. The server never prints the key.
The package prints the skill. Run the lines for your tool once:
# Claude Code
mkdir -p ~/ .claude/ skills/ vulsight-guard
npx -y @vulsight/ guard-mcp --skill > ~/ .claude/ skills/ vulsight-guard/ SKILL.md
# Codex, once, in the project the agent works in
npx -y @vulsight/ guard-mcp --skill codex >> AGENTS.mdIn Claude Code a hook can file every tool result on its own, so the agent does not have to remember. npx -y @vulsight/guard-mcp --hook prints this block; add its hooks object to ~/.claude/settings.json. After each tool call it posts the result with the tool's name as its origin, skips the guard's own tools (the server must be installed under a name containing vulsight, as the install line does) and AskUserQuestion, and exits 0 on any runtime failure, so a guard that is down never stops the agent. It reads VULSIGHT_API_KEY and VULSIGHT_BASE_URL from the shell Claude Code was started in.
The hook and the server share one session from the Claude Code project directory, so a page the check blocks can deny the next check_payment. It runs in the background, so a filing can land after a check_payment made a moment later. The post has 105 seconds to finish, within the hook's 120-second timeout. Treat it as background defense: before paying, the agent files the content it is about to act on with file_context, as the skill says, and the declared payment and the rules carry the enforcement.
With Codex, the agent calls file_context. The config carries tool_timeout_sec = 450 to leave room for filing, the decision, and two minutes for a reviewer; raise it further for a longer review wait. Claude Code needs no such line.
Any other MCP client (Cursor, Windsurf, Cline, VS Code, Zed, or an agent you built on an MCP SDK) takes the same server through its own config. Cursor, Windsurf, and Cline use the JSON on the third tab; VS Code and Zed take the same command, args, and env under their own key (servers and context_servers). If the client caps how long a tool call may run, raise the cap to at least 450 seconds or pass a shorter wait_seconds. Paste the skill text into the client's rules file so the agent knows to call the tool before it pays. An agent built on a framework rather than an MCP client (the Vercel AI SDK, LangChain, the OpenAI Agents SDK) uses the SDK instead, so the check runs in code rather than waiting for the agent to call a tool.
{
"hooks": {
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "npx -y @vulsight/ guard-mcp file-tool-result",
"async": true,
"timeout": 120
}
]
}
]
}
}The hook files every tool result, so a secret a tool prints (a .env read, printenv) is sent to the guard and kept in your account's private content history until the account is deleted. The dashboard shows an excerpt; the saved history keeps the complete submitted text and check results. The hook and the server must use the same API key: the hold is matched by key and session, so a filing under another key never holds a check_payment. The hook therefore shares that key's per-minute rate limit, and a very chatty tool loop can hit the ceiling, after which the hook drops filings, one line on stderr each, until the minute passes. Your own messages are never filed: you are the principal, and the check is for untrusted content.
Ask it about the demo seller
Tell your agent to buy the product reviews dataset at https://vulsight-guard.vercel.app/merchant/dataset. It reads the page, takes the terms from the 402, and calls the tool with them. Amounts are decimal strings in the asset's smallest unit, so 50000 is 0.05 test USDC on Base Sepolia.
{
"payment": {
"kind": "x402_payment",
"payTo": "0x1111111111111111111111111111111111111111",
"amountAtomic": "50000",
"asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"network": "eip155:84532",
"scheme": "exact",
"resourceUrl": "https:// vulsight-guard.vercel.app/ merchant/ dataset"
},
"context": "Dataset: 10,000 labeled product reviews. Price 0.05 USDC via x402."
}A missing field comes back as a short answer naming it, so the agent can fix the call and try again rather than guess.
Denied and review answers
Every result is plain sentences the agent can quote. A deny starts with the instruction not to pay, then gives the reasons, and ends with the decision id.
VulSight Guard denied this payment. Do not pay.
The payment is addressed to 0x2222…2222 but the seller's own 402 asked for 0x1111…1111.
The amount 2.50 USDC is over the per-transaction limit of 0.10 USDC.
Decision id 9107cf25-aa34-4d7b-a546-c8b5253634a3.A payment in review holds the tool call while a person decides, up to wait_seconds. If nobody answers, the tool says so and names the next step rather than letting the agent pay.
VulSight Guard sent this payment to a person for review and nobody has answered yet. Do not pay until it is approved.
Nobody answered within 120 seconds. Ask your user to review it in the VulSight Guard dashboard.
First payment to 0x3333…3333. Auto-allow for first payments is off in your policy.
Decision id daf814c5-e82b-41d3-8af2-cb71b5ccd6d8.A hook that stops the payment in the Claude Agent SDK
An agent built on the Claude Agent SDK runs hooks in your own process, before and after each tool. A PreToolUse hook on the tools the agent pays with asks the guard first and answers the SDK with a permission decision, so a denied payment is never made and a held one is refused with its decision id. When the guard does not answer, the hook denies too.
import type {
HookCallback,
HookJSONOutput,
PostToolUseHookInput,
PreToolUseHookInput,
} from "@anthropic-ai/ claude-agent-sdk";
const base = process.env.VULSIGHT_BASE_URL ?? "https:// vulsight-guard.vercel.app";
const headers = {
authorization: `Bearer ${process.env.VULSIGHT_API_KEY}`,
"content-type": "application/ json",
"x-vulsight-channel": "api",
};
const sessionId = crypto.randomUUID();
// The hash of the last page filed, sent with the payment so it inherits that page's result.
let lastFiled: string | undefined;
// A payment sent to a person keeps its decision id, so the retry after the approval reads that
// decision instead of opening a second hold.
const held = new Map<string, string>();
type Decision = {
id: string;
status: string;
rules: { result: string; sentence?: string }[];
};
const allow: HookJSONOutput = {
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
};
const deny = (permissionDecisionReason: string): HookJSONOutput => ({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason,
},
});
export const checkPayment: HookCallback = async (input) => {
const { tool_input } = input as PreToolUseHookInput;
const { payTo, amountAtomic, asset, network, scheme, resourceUrl } =
tool_input as Record<string, string>;
const payment = JSON.stringify([network, payTo, amountAtomic, asset, resourceUrl]);
// Taken out before any await, so two attempts at once cannot both read one approval.
const heldId = held.get(payment);
held.delete(payment);
let decision: Decision;
try {
const answer = heldId
? await fetch(`${base}/ api/ v1/ decisions/ ${heldId}`, {
headers,
signal: AbortSignal.timeout(105_000),
})
: await fetch(`${base}/ api/ v1/ decisions`, {
method: "POST",
headers,
body: JSON.stringify({
kind: "x402_payment",
payTo, amountAtomic, asset, network, scheme, resourceUrl, sessionId,
contextSha256: lastFiled,
}),
signal: AbortSignal.timeout(105_000),
});
const body = await answer.json();
if (!answer.ok) {
// A guard that is busy or down keeps the hold for the next attempt; a refused read drops it.
if (heldId && (answer.status >= 500 || answer.status === 429)) held.set(payment, heldId);
return deny(`VulSight Guard did not decide. ${body.error.message} Do not pay.`);
}
decision = body;
} catch {
if (heldId) held.set(payment, heldId);
return deny("VulSight Guard did not answer. Do not pay.");
}
if (decision.status === "review_pending") {
held.set(payment, decision.id);
return deny(
`Held for a person. Approve or deny it on Review, decision ${decision.id}, then try the same payment again.`,
);
}
if (decision.status === "allowed" || decision.status === "review_approved") return allow;
if (decision.status === "review_denied") {
return deny("A reviewer denied this payment. Do not pay.");
}
if (decision.status === "review_expired") {
return deny("The review expired before anyone answered. Do not pay.");
}
const denied = decision.rules.filter((r) => r.result === "deny");
return deny(denied.map((r) => r.sentence).join(" "));
};What this holds and what it does not. The hook runs before the tool, so in enforce mode a denied payment is never made, and a payment sent to a person waits on the Review page in your account while the hook refuses the tool call with its decision id. The run goes on, so the agent can tell the person where to answer; return continue: false as well to end it there. The hook keeps that decision id for the payment, so once a person approves it on Review the agent's next attempt at the same payment reads that decision and goes through once, and a review a person denied or nobody answered is refused in words the agent can quote. In observe mode the guard answers allowed for everything except the three denials the security model page names, a wrong network, a wrong asset, and a payee other than the one the seller's own 402 named, so the hook lets the rest through and the record says what enforce would have done. The third needs the 402's address, which this hook sends only if your payment tool passes it (add it to the body as observed402PayTo). The agent has to be running under the SDK with the hook installed, so this is cooperative like the tool above, not the proxy URL. An agent that pays outside the SDK is not stopped.
The content check reads only what is filed, so a second hook, in the same file, files the page the agent read, not the address it asked for. A PostToolUse hook on the fetch and browse tools posts the returned text under the same session, and waits for the filing, so a page carrying instructions aimed at the agent holds the payment the agent tries next. The filing's reply names the page's hash, and the payment hook sends it as contextSha256, so the payment inherits that page's own result: a session alone carries only a flag, and a check that did not answer would otherwise be lost and a first payment let through unchecked. A filing the guard refuses or does not answer (a key past its per-minute limit, a guard that is down) is one line on stderr and never stops the agent.
// guard.ts, continued: base, headers, sessionId, and lastFiled come from the block above.
export const fileWhatItRead: HookCallback = async (input) => {
const { tool_name, tool_response } = input as PostToolUseHookInput;
const page =
typeof tool_response === "string" ? tool_response : JSON.stringify(tool_response);
if (!page) return {};
// The first 64 KiB, whole characters, each NUL as the replacement character (the guard
// refuses a NUL), as the shipped clients file it.
const bytes = new TextEncoder()
.encode(page.replaceAll("\u0000", "\uFFFD"))
.subarray(0, 65_536);
const text = new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes, { stream: true });
const answer = await fetch(`${base}/ api/ v1/ context`, {
method: "POST",
headers,
body: JSON.stringify({ sessionId, origin: tool_name, text }),
signal: AbortSignal.timeout(105_000),
}).catch(() => undefined);
if (!answer?.ok) {
const why = answer?.status ?? "no answer";
console.error(`VulSight Guard did not file ${tool_name}: ${why}`);
return {};
}
lastFiled = (await answer.json()).sha256;
return {};
};Register both where the agent starts. The matcher timeout keeps the SDK's own hook limit above the guard's wait, so a slow answer is a deny and never a tool call.
import { query } from "@anthropic-ai/ claude-agent-sdk";
import { checkPayment, fileWhatItRead } from "./ guard";
// Name the tools your agent pays with in the first matcher.
for await (const message of query({
prompt: "Buy the dataset at https:// vulsight-guard.vercel.app/ merchant/ dataset.",
options: {
hooks: {
PreToolUse: [
{ matcher: "pay_x402|mcp__wallet__pay", hooks: [checkPayment], timeout: 120 },
],
PostToolUse: [
{ matcher: "WebFetch|mcp__browser__.*", hooks: [fileWhatItRead], timeout: 120 },
],
},
},
})) {
console.log(message);
}Its limit
This one is cooperative. The tool asks before paying, and it cannot stop an agent that ignores it. Enforcement comes from the proxy URL or the SDK hook, which check every payment on their path whatever the agent intends. If your agent pays through some other tool, send that traffic through the proxy and keep this one for the reasons it can quote.
Text filed through context or file_context belongs to one session: the value of VULSIGHT_SESSION when it is set, else one per Claude Code project directory, else one per server process. Under Claude Code the hook files into that same session. With content enforcement on, a completed content block denies later payments in that session for an hour, even when the payment itself looks fine. An unfinished check requires review, and an earlier approval of a page never overrides a new completed block. A session that files faster than its key's per-minute limit (or the account's, across every key) gets a 429, which the tool reports as an error and the hook drops with one line on stderr. A file_context filing or a hook filing never blocks the agent. A context filing that fails inside check_payment fails the check closed, and the answer says do not pay.