MCP server

Put Shotoku between your agent and the tools it can reach, so every call is checked against your policy before it runs — and the agent cannot opt out.

Two ways to use it

Decide this first. It changes the whole setup, and only one of the two is actually enforcement.

WrappingAdvisory
What it doesShotoku re-exposes your other MCP servers and gates every callThe agent calls authorize_action when it decides to
Can the agent skip itNo — it has no other route to the toolsYes — nothing compels the call
What it is good forReal enforcement over existing MCP serversActions with no MCP server behind them: purchases, your own API calls, code execution
SetupSet SHOTOKU_MCP_UPSTREAMS and move your servers behind ShotokuAdd Shotoku alongside your other servers

They are not exclusive. Wrapping covers everything reachable through MCP; advisory covers everything else. Most setups end up using both.


Install

Nothing to clone or build. The server runs straight from npm:

bash
npx shotoku-mcp

It speaks MCP over stdin, so running it by hand just sits there waiting — that is correct. Your agent host is what launches it. You will also want the CLI, which is how you review and approve what the agent asked for:

bash
npm install -g shotoku-cli

Setup: wrapping

Shotoku connects to your other MCP servers, re-exposes each of their tools under its own name, and gates every call. The agent talks to Shotoku and nothing else.

text
before    agent ──────────────────────► obsidian
after     agent ──► shotoku ──► policy ──► obsidian

1. Set up a home for Shotoku

Your agent host launches the server with a working directory it chooses — often the filesystem root. Give Shotoku a fixed place to keep its policy and its ledger so that never matters:

bash
mkdir -p ~/shotoku-home
cd ~/shotoku-home
shotoku init

2. List the servers to put behind Shotoku

The format is the same mcpServers shape your agent host already uses, so entries usually move across unchanged.

~/shotoku-home/upstreams.json
{
  "mcpServers": {
    "obsidian": {
      "command": "/path/to/obsidian-mcp-server",
      "env": { "OBSIDIAN_API_KEY": "..." }
    },
    "reddit": {
      "command": "npx",
      "args": ["-y", "reddit-mcp-buddy"]
    }
  }
}

3. Point your agent host at Shotoku

Replace the entries you just moved. This is the whole config — Shotoku is now the only MCP server your host knows about:

claude_desktop_config.json
{
  "mcpServers": {
    "shotoku": {
      "command": "npx",
      "args": ["-y", "shotoku-mcp"],
      "env": {
        "SHOTOKU_HOME": "/absolute/path/to/shotoku-home",
        "SHOTOKU_MCP_UPSTREAMS": "/absolute/path/to/shotoku-home/upstreams.json"
      }
    }
  }
}
Leave the old entries in and the gate is decorative. The agent keeps a direct route to those servers and will use it. Wrapping only enforces anything if Shotoku is the only way through.

On macOS the file is at ~/Library/Application Support/Claude/claude_desktop_config.json; on Windows, %APPDATA%\Claude\claude_desktop_config.json. Restart the app afterwards.

4. Write rules for the wrapped tools

A wrapped tool is named <server>__<tool>, and that name is the resource in your policy. This is the single most useful thing to know about wrapping.

~/shotoku-home/policy.yaml
rules:
  # Reading is free
  - resource: obsidian__get_*
    verdict: approved
  - resource: obsidian__search_*
    verdict: approved

  # Writing is allowed, and recorded
  - resource: obsidian__create_*
    verdict: approved
  - resource: obsidian__append_*
    verdict: approved

  # Deleting is not something an agent decides alone
  - resource: obsidian__delete_*
    verdict: pending_approval

  # Read-only server, no friction
  - resource: reddit__*
    verdict: approved

defaultVerdict: pending_approval

Keep defaultVerdict: pending_approval. A new tool appearing in an upstream server after an update is then held for review instead of silently allowed.

5. Check it worked

In Claude Desktop, open the tools icon in a new conversation. You should see Shotoku’s own tools plus one entry per wrapped tool, prefixed: obsidian__search_vault, reddit__browse_subreddit, and so on. Then, in a terminal:

bash
shotoku where

The ledger it prints must be the same file the server writes to. If they differ, the agent’s decisions are going somewhere you are not looking — the most common cause of an approval queue that appears empty.


Setup: advisory

For actions with no MCP server behind them — a purchase, a paid API call, running code — the agent asks Shotoku directly. Same install, no upstreams file, and Shotoku sits alongside your other servers rather than in front of them:

json
{
  "mcpServers": {
    "shotoku": {
      "command": "npx",
      "args": ["-y", "shotoku-mcp"],
      "env": { "SHOTOKU_HOME": "/absolute/path/to/shotoku-home" }
    }
  }
}

Be clear-eyed about what this is: the agent decides whether to ask. It is a guardrail and an audit trail, not a barrier. Use wrapping wherever a tool server exists.


The tools

A default install exposes three:

ToolWhat it does
authorize_actionAsk whether an action is allowed. Returns the full decision with reasons
get_decisionLook up a past decision by ID
get_pending_approvalsList decisions waiting for a human

Three, not five. approve_decision and deny_decision exist but are withheld and refused if called: an agent that can approve its own pending decisions has not been gated, it has been given a form to fill in.

authorize_action

FieldRequiredWhat it means
actionyesOne of purchase, api_call, execute_code, send_email, mcp_tool, custom
resourceyesWhat it is acting on — a domain, service name, or endpoint
amountnoCost in USD, if any
contextnoExtra detail recorded with the decision (JSON, max 16 KB)

There is deliberately no actor field — see below.


Who the decision is attributed to

Every decision is recorded against an actor. Shotoku sets it; the agent does not.

An MCP client announces its own name during the initialize handshake, before it can make a single tool call, and the model never sees that value. A connection from Claude Desktop is recorded as claude-desktop, one from Cursor as cursor, with no configuration.

To name a server yourself — one entry per agent, each with its own budget — set SHOTOKU_MCP_ACTOR in that entry’s env, then write rules that apply to it alone:

yaml
rules:
  - resource: "*"
    actor: billing-agent
    verdict: approved
    maxDailyAmount: 50
Distinguishable, not unforgeable. An agent with shell access can edit a config file, or skip Shotoku altogether. The guarantee is narrower and still worth having: every call that goes through Shotoku is attributed to a named agent, and an agent cannot rename itself through the protocol.

Approving what the agent asked for

Approval happens where the agent cannot reach it — your terminal:

bash
shotoku status
shotoku approve dec_abc123

Or in the TUI (shotoku tui), which is the same queue with keyboard navigation.

What happens after you approve

An approval is redeemable once, by the next call matching it exactly — same agent, same tool, same arguments, same amount. The agent retries, the call goes through, and the approval is spent.

text
agent   → obsidian__delete_vault_file   ◷ pending, dec_21028c
you     → shotoku approve dec_21028c
agent   → obsidian__delete_vault_file   ✓ approved
agent   → obsidian__delete_vault_file   ◷ pending again

That last line is the design, not a bug. Approving an action is not approving it forever — standing permission is what policy.yaml is for. Approvals also expire, after fifteen minutes by default (grantTtlSeconds).

shotoku status lists approvals waiting to be used, so one never disappears into nothing:

text
1 approval waiting to be used by the agent:
  ✓  claude-desktop · obsidian__delete_vault_file · expires in 12m

What the agent is told when blocked

text
Blocked by Shotoku — this action was not authorized.
  status:   pending_approval
  decision: dec_cd9b9664cf9f
  reason:   obsidian__delete_vault_file matched rule
  A human must approve this out-of-band (`shotoku approve dec_cd9b9664cf9f`,
  the Shotoku TUI, or Slack). You cannot approve it yourself — ask the user.
  Once they approve, retry this exact call: the approval is redeemable once,
  for these arguments only. Changing anything requires a new approval.

A denial says so plainly and tells the agent not to retry.


What ends up in the ledger

Every gated call is recorded: the agent, the server, the tool, the verdict, the reasons, and a hash linking it to the record before it. For wrapped calls the names of the arguments are recorded — never their values. Tool arguments routinely carry API keys, file contents and personal data, and an audit log is not a place to accumulate them.

The values are still bound to the approval, through a digest. That is what stops an approval for one file from being redeemed against another.

bash
shotoku history --since 24h
shotoku decision dec_21028c

Environment variables

VariableDefaultWhat it controls
SHOTOKU_HOME~/.shotokuWhere policy and ledger live when nothing else says otherwise
SHOTOKU_MCP_UPSTREAMSunsetPath to the upstreams file. Setting it turns on wrapping
SHOTOKU_MCP_ACTORthe client nameOverrides the agent name recorded in the ledger
SHOTOKU_MCP_ALLOW_APPROVALSunset1 hands the approval tools to the agent. It removes the separation the product exists to provide
SHOTOKU_POLICYfrom the homePath to the policy file
SHOTOKU_LEDGERfrom the homePath to the decision ledger
SHOTOKU_CONFIGshotoku.config.jsonPath to the Shotoku config file
SHOTOKU_RECEIPT_SECRETunsetWhen set, approved decisions carry a signed receipt

Which files the server actually uses

Every Shotoku process resolves the same way, highest priority first:

  1. SHOTOKU_POLICY / SHOTOKU_LEDGER
  2. a shotoku.config.json, searched for from the working directory upward
  3. an existing ./data/decisions.jsonl beside the working directory
  4. $SHOTOKU_HOME, otherwise ~/.shotoku

shotoku where prints what a directory resolves to and what decided it. Run it in your terminal and compare with your host config — if the two disagree, the server is writing decisions the CLI cannot see.


When something is wrong

SymptomAlmost always
The host says the server failed to startCheck its MCP logs — Shotoku names the reason on stderr. Usually the upstreams file: invalid JSON, or a path that is not absolute.
No prefixed tools appearWrapping is off or matched nothing. SHOTOKU_MCP_UPSTREAMS must point at a file with an mcpServers object, and each entry needs a command. Shotoku says which entry it rejected.
The agent still reaches a wrapped server directlyIts entry is still in your host config. Wrapping only enforces anything when Shotoku is the only route.
shotoku status is empty but the agent says it is blockedThe CLI and the server are reading different ledgers. Run shotoku where and compare with the host config.
Everything is denied, including allowed thingsThe policy file failed to load and Shotoku fails closed. Run any shotoku authorize; the reason names the problem.
An approval does not let the retry throughSomething in the call changed — a different argument or amount — or more than fifteen minutes passed. An approval binds to the exact call.