Skip to content

feat(mcp): only register read-only tools in read-only mode#169

Merged
aprimakina merged 2 commits into
mainfrom
mcp-read-only-tool-registration
Jun 26, 2026
Merged

feat(mcp): only register read-only tools in read-only mode#169
aprimakina merged 2 commits into
mainfrom
mcp-read-only-tool-registration

Conversation

@aprimakina

Copy link
Copy Markdown
Contributor

When read_only is enabled, the MCP server no longer registers the service-mutating tools (create, fork, start, stop, resize, update_password), so clients never see them and won't attempt them or prompt for inputs that would only fail. Read tools and db_execute_query (which connects read-only) stay registered.

A generic addTool wrapper skips registration for tools in readOnlyGatedTools when the server is read-only; that slice is the single source of truth, shared with the server instructions. The handler-level common.CheckReadOnly guards are kept as a backstop for read_only being toggled on mid-session.

@aprimakina aprimakina self-assigned this Jun 22, 2026
@aprimakina aprimakina force-pushed the mcp-read-only-tool-registration branch 3 times, most recently from 1cb1af2 to 688e86c Compare June 24, 2026 09:57
@aprimakina aprimakina marked this pull request as ready for review June 24, 2026 09:57
@aprimakina aprimakina force-pushed the mcp-read-only-tool-registration branch from 688e86c to 20051cf Compare June 24, 2026 10:22
@aprimakina aprimakina force-pushed the mcp-read-only-tool-registration branch 2 times, most recently from 4822219 to 7d1cf2f Compare June 24, 2026 11:51

@nathanjcochran nathanjcochran left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a couple minor comments, but overall LGTM. My main question is whether we really want to check that custom hook into the repo...?

Comment thread .claude/hooks/deny-tiger-cli.sh Outdated
Comment on lines +1 to +21
#!/usr/bin/env bash
# PreToolUse(Bash) hook: deny commands that invoke the Tiger CLI, so the agent
# uses the Tiger MCP tools instead of shelling out. Matches `tiger`,
# `./bin/tiger`/`bin/tiger`, and `go run ./cmd/tiger` (incl. a leading `VAR=val`),
# but not path mentions like `go build -o bin/tiger ./cmd/tiger`.
set -euo pipefail

cmd=$(jq -r '.tool_input.command // ""')

# Match a tiger invocation at a command boundary (start or after a separator),
# allowing leading `VAR=val ` env assignments.
boundary='(^|[;&|(]|&&|\|\|)[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+)*'
invocation="${boundary}((\./)?(bin/)?tiger([[:space:]]|\$)|go[[:space:]]+run[[:space:]]+[^;&|]*cmd/tiger)"

if [[ $cmd =~ $invocation ]]; then
cat <<'JSON'
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Running the Tiger CLI from the agent is not allowed. Use the Tiger MCP tools instead of shelling out to the CLI."}}
JSON
fi

exit 0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really something that you want to check into the repo? Doesn't this mean it will be enabled for everyone (at least everyone using Claude Code)? I think there are valid cases where engineers do want Claude to execute the tiger CLI commands directly (e.g. when asking Claude to test a CLI command). Because of that, I'd personally vote to keep this out of the repo.

Fwiw, I also think there are probably easier ways to deny Claude Code from executing tiger commands. Like adding "Bash(tiger:*)" (and maybe a couple other patterns) to the permissions.deny list in your .claude/settings.local.json file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While testing the changes, I noticed that Claude may try to call the CLI commands directly instead of the MCP tool, even without explicit confirmation for a user. Thus, the benefits of filtering out certain tools in read-only mode are questionable 🤷‍♀️ This was an attempt to forbid it. I see that the hook may also block valid use-cases.

@aprimakina aprimakina Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we should care about it? We can, e.g., add a recommendation to avoid calling the CLI directly in read-only mode to server instructions. That would be less restrictive.

@nathanjcochran nathanjcochran Jun 25, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While testing the changes, I noticed that Claude may try to call the CLI commands directly instead of the MCP tool, even without explicit confirmation for a user. Thus, the benefits of filtering out certain tools in read-only mode are questionable 🤷‍♀️ This was an attempt to forbid it.

Well, for one thing, this hook only applies to people who have checked out this repo and started a Claude session from within it. The hook isn't being packaged into Tiger CLI itself (or a Claude Code plugin that end users could download, or anything like that), so it isn't applying to end-users - only to us developers. So I don't think this is really accomplishing what you set out to solve anyways.

But also, in my experience, I've only seen Claude try to use the tiger CLI directly when I'm testing the MCP from a Claude session started within the tiger-cli repo on my machine, because in that case, the repo's CLAUDE.md provides a bunch of context about the CLI to the agent, which influences the agent to sometimes use the CLI. But outside of that, I think agents typically won't even know there is a tiger CLI - they will only know about the MCP tools that they see in the context. So I don't think this should be a problem for most end-users. Maybe try to do some testing from a Claude session started in a different directory (with no CLAUDE.md), and see if it still tries to use the CLI then?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested from a clean dir with only the MCP installed. The agent never shells out to the CLI. Dropping the hook.

Comment thread .claude/settings.json Outdated
Comment on lines +1 to +15
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/deny-tiger-cli.sh\""
}
]
}
]
}
}

@nathanjcochran nathanjcochran Jun 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could probably put this in your .claude/settings.local.json file instead (which I think is ignored by git, by default). That way, it would be configured for you personally, but wouldn't affect how other engineers chose to use Claude when working in this repo.

Comment thread internal/tiger/mcp/server.go Outdated
Comment on lines +31 to +33
// readOnly is captured from config at construction time. Handlers still call
// common.CheckReadOnly in case read_only is toggled on mid-session.
readOnly bool

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd vote to keep this off of the Server struct. The Server struct is the receiver type of all the MCP tool handler functions, which means this potentially stale value is accessible to all of them. I think the temptation to reference it (instead of checking the value on the re-loaded config) would just be too great.

Since it's really only needed when registering tools, I'd just pass it down into registerTools() (and registerServiceTools()/registerDatabaseTools()) as a regular function parameter. That way, it won't remain accessible to the handler functions for the lifetime of the MCP server.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Comment on lines +36 to 43
// addTool registers an MCP tool, skipping readOnlyGatedTools in read-only mode.
func addTool[In, Out any](s *Server, t *mcp.Tool, h mcp.ToolHandlerFor[In, Out]) {
if s.readOnly && slices.Contains(readOnlyGatedTools, t.Name) {
logging.Debug("Skipping write tool in read-only mode", zap.String("tool", t.Name))
return
}
mcp.AddTool(s.mcpServer, t, h)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also not entirely convinced we need this helper function. Could just as easily have conditionals in registerServiceTools()/registerDatabaseTools() that check readOnly and don't add the read-only tools if it's true. But if you like having a single consolidated list of read-only tools (readOnlyGatedTools), that's fine too. I just find it a little more roundabout, personally 🤷‍♂️.

@aprimakina aprimakina force-pushed the mcp-read-only-tool-registration branch 2 times, most recently from 8fca2dc to 1992105 Compare June 25, 2026 17:52
aprimakina and others added 2 commits June 25, 2026 23:04
When read_only is enabled, the MCP server no longer registers the
service-mutating tools (create, fork, start, stop, resize,
update_password), so clients never see them and won't attempt them or
prompt for inputs that would only fail. Read tools and db_execute_query
(which connects read-only) stay registered.

A generic addTool wrapper skips registration for tools in
readOnlyGatedTools when the server is read-only; that slice is the single
source of truth, shared with the server instructions. The handler-level
common.CheckReadOnly guards are kept as a backstop for read_only being
toggled on mid-session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Pass readOnly as a parameter through registerTools/registerServiceTools/
  registerDatabaseTools instead of storing it on the Server struct, so the
  stale-once-set value can't leak to tool handlers.
- Remove the deny-tiger-cli.sh hook and .claude/settings.json; this dev-only
  tooling shouldn't be checked in repo-wide.
- Update README read_only/TIGER_READ_ONLY docs to match spec_mcp.md: write
  MCP tools are not registered (absent from tools/list), not erroring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aprimakina aprimakina force-pushed the mcp-read-only-tool-registration branch from 1992105 to f9659be Compare June 25, 2026 21:04
@aprimakina aprimakina merged commit 555ad3a into main Jun 26, 2026
2 of 3 checks passed
@aprimakina aprimakina deleted the mcp-read-only-tool-registration branch June 26, 2026 07:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants