feat(mcp): only register read-only tools in read-only mode#169
Conversation
1cb1af2 to
688e86c
Compare
688e86c to
20051cf
Compare
4822219 to
7d1cf2f
Compare
nathanjcochran
left a comment
There was a problem hiding this comment.
Left a couple minor comments, but overall LGTM. My main question is whether we really want to check that custom hook into the repo...?
| #!/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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Tested from a clean dir with only the MCP installed. The agent never shells out to the CLI. Dropping the hook.
| { | ||
| "hooks": { | ||
| "PreToolUse": [ | ||
| { | ||
| "matcher": "Bash", | ||
| "hooks": [ | ||
| { | ||
| "type": "command", | ||
| "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/deny-tiger-cli.sh\"" | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| // readOnly is captured from config at construction time. Handlers still call | ||
| // common.CheckReadOnly in case read_only is toggled on mid-session. | ||
| readOnly bool |
There was a problem hiding this comment.
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.
| // 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) | ||
| } |
There was a problem hiding this comment.
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 🤷♂️.
8fca2dc to
1992105
Compare
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>
1992105 to
f9659be
Compare
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.