Skip to content

Commit 9be1c39

Browse files
aprimakinaclaude
andcommitted
feat(mcp): only register read-only tools in read-only mode
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>
1 parent ac3c0f2 commit 9be1c39

9 files changed

Lines changed: 188 additions & 50 deletions

File tree

.claude/hooks/deny-tiger-cli.sh

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env bash
2+
# PreToolUse(Bash) hook: deny commands that invoke the Tiger CLI, so the agent
3+
# uses the Tiger MCP tools instead of shelling out. Matches `tiger`,
4+
# `./bin/tiger`/`bin/tiger`, and `go run ./cmd/tiger` (incl. a leading `VAR=val`),
5+
# but not path mentions like `go build -o bin/tiger ./cmd/tiger`.
6+
set -euo pipefail
7+
8+
cmd=$(jq -r '.tool_input.command // ""')
9+
10+
# Match a tiger invocation at a command boundary (start or after a separator),
11+
# allowing leading `VAR=val ` env assignments.
12+
boundary='(^|[;&|(]|&&|\|\|)[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+)*'
13+
invocation="${boundary}((\./)?(bin/)?tiger([[:space:]]|\$)|go[[:space:]]+run[[:space:]]+[^;&|]*cmd/tiger)"
14+
15+
if [[ $cmd =~ $invocation ]]; then
16+
cat <<'JSON'
17+
{"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."}}
18+
JSON
19+
fi
20+
21+
exit 0

.claude/settings.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"hooks": {
3+
"PreToolUse": [
4+
{
5+
"matcher": "Bash",
6+
"hooks": [
7+
{
8+
"type": "command",
9+
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/deny-tiger-cli.sh\""
10+
}
11+
]
12+
}
13+
]
14+
}
15+
}

internal/tiger/mcp/db_tools.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,8 @@ func (DBExecuteQueryOutput) Schema() *jsonschema.Schema {
148148

149149
// registerDatabaseTools registers database operation tools with comprehensive schemas and descriptions
150150
func (s *Server) registerDatabaseTools() {
151-
mcp.AddTool(s.mcpServer, &mcp.Tool{
152-
Name: "db_execute_query",
151+
addTool(s, &mcp.Tool{
152+
Name: toolDBExecuteQuery,
153153
Title: "Execute SQL Query",
154154
Description: `Execute SQL queries against a service database.
155155

internal/tiger/mcp/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package mcp
22

3+
// readOnlyGatedTools are the service-mutating tools addTool skips in read-only mode.
34
var readOnlyGatedTools = []string{
45
toolServiceCreate,
56
toolServiceFork,

internal/tiger/mcp/errors_test.go

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,34 +10,29 @@ import (
1010
func TestBuildServerInstructions(t *testing.T) {
1111
const capabilitiesMarker = "Tiger MCP provides tools"
1212

13-
for _, tt := range []struct {
14-
name string
15-
cfg *config.Config
16-
}{
17-
{name: "nil cfg", cfg: nil},
18-
{name: "read-only off", cfg: &config.Config{ReadOnly: false}},
19-
} {
20-
t.Run(tt.name, func(t *testing.T) {
21-
got := buildServerInstructions(tt.cfg)
22-
if !strings.Contains(got, capabilitiesMarker) {
23-
t.Errorf("instructions missing capabilities blurb: %q", got)
24-
}
25-
if strings.Contains(got, "READ-ONLY MODE IS ENABLED") {
26-
t.Errorf("instructions should not contain read-only banner when read-only is off: %q", got)
27-
}
28-
})
13+
readWrite := buildServerInstructions(&config.Config{ReadOnly: false})
14+
readOnly := buildServerInstructions(&config.Config{ReadOnly: true})
15+
16+
// The capabilities blurb is always present, including for a nil config.
17+
for _, got := range []string{buildServerInstructions(nil), readWrite, readOnly} {
18+
if !strings.Contains(got, capabilitiesMarker) {
19+
t.Errorf("instructions missing capabilities blurb: %q", got)
20+
}
2921
}
3022

31-
got := buildServerInstructions(&config.Config{ReadOnly: true})
32-
if !strings.Contains(got, capabilitiesMarker) {
33-
t.Errorf("instructions missing capabilities blurb: %q", got)
23+
// The read-only banner appears only in read-only mode.
24+
const banner = "READ-ONLY MODE IS ENABLED"
25+
if !strings.Contains(readOnly, banner) {
26+
t.Errorf("read-only instructions missing banner: %q", readOnly)
3427
}
35-
if !strings.Contains(got, "READ-ONLY MODE IS ENABLED") {
36-
t.Errorf("instructions missing read-only banner: %q", got)
28+
if strings.Contains(readWrite, banner) {
29+
t.Errorf("read-write instructions should not contain banner: %q", readWrite)
3730
}
31+
32+
// Read-only instructions never name the gated write tools.
3833
for _, tool := range readOnlyGatedTools {
39-
if !strings.Contains(got, tool) {
40-
t.Errorf("instructions missing gated tool %q in: %q", tool, got)
34+
if strings.Contains(readOnly, tool) {
35+
t.Errorf("read-only instructions should not name gated tool %q: %q", tool, readOnly)
4136
}
4237
}
4338
}

internal/tiger/mcp/server.go

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
"errors"
77
"fmt"
88
"net/http"
9-
"strings"
9+
"slices"
1010
"time"
1111

1212
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -27,25 +27,35 @@ const (
2727
type Server struct {
2828
mcpServer *mcp.Server
2929
docsProxyClient *ProxyClient
30+
31+
// readOnly is captured from config at construction time. Handlers still call
32+
// common.CheckReadOnly in case read_only is toggled on mid-session.
33+
readOnly bool
34+
}
35+
36+
// addTool registers an MCP tool, skipping readOnlyGatedTools in read-only mode.
37+
func addTool[In, Out any](s *Server, t *mcp.Tool, h mcp.ToolHandlerFor[In, Out]) {
38+
if s.readOnly && slices.Contains(readOnlyGatedTools, t.Name) {
39+
logging.Debug("Skipping write tool in read-only mode", zap.String("tool", t.Name))
40+
return
41+
}
42+
mcp.AddTool(s.mcpServer, t, h)
3043
}
3144

32-
// buildServerInstructions returns the `instructions` string the MCP SDK
33-
// sends to clients at initialize.
34-
//
35-
// Instructions are evaluated once at server start; toggling read_only
36-
// mid-session leaves the warning stale until the MCP client restarts. The
37-
// gate itself stays correct because handlers reload config per call.
45+
// buildServerInstructions returns the `instructions` string the MCP SDK sends
46+
// to clients at initialize. Evaluated once at server start, like tool registration.
3847
func buildServerInstructions(cfg *config.Config) string {
39-
base := "Tiger MCP provides tools for creating, managing, and querying Tiger Cloud database services (managed TimescaleDB/PostgreSQL). " +
40-
"Use it to provision and fork services, start/stop/resize instances, rotate credentials, fetch service logs, execute SQL queries, and search Tiger documentation."
48+
intro := "Tiger MCP provides tools for managing and querying Tiger Cloud database services (managed TimescaleDB/PostgreSQL). "
4149

4250
if cfg == nil || !cfg.ReadOnly {
43-
return base
51+
return intro +
52+
"Use it to provision and fork services, start/stop/resize instances, rotate credentials, fetch service logs, execute SQL queries, and search Tiger documentation."
4453
}
45-
return base + " " +
46-
"READ-ONLY MODE IS ENABLED. The following Tiger MCP tools will refuse to run: " +
47-
strings.Join(readOnlyGatedTools, ", ") + ". " +
48-
"Before asking the user to provide inputs for any of these operations, tell them read-only mode is on."
54+
// Read-only mode: announce the mode and the blocked operations so the model
55+
// won't attempt them.
56+
return intro +
57+
"READ-ONLY MODE IS ENABLED. Service-mutating tools are not registered, so do not offer to create, fork, start, stop, resize, or modify services. " +
58+
"db_execute_query connects read-only, so writes and DDL are rejected by the server."
4959
}
5060

5161
// NewServer creates a new Tiger MCP server instance. The caller-supplied cfg
@@ -59,6 +69,7 @@ func NewServer(ctx context.Context, cfg *config.Config) (*Server, error) {
5969

6070
server := &Server{
6171
mcpServer: mcpServer,
72+
readOnly: cfg != nil && cfg.ReadOnly,
6273
}
6374

6475
// Register all tools (including proxied docs tools)

internal/tiger/mcp/server_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"slices"
6+
"testing"
7+
8+
"github.com/modelcontextprotocol/go-sdk/mcp"
9+
10+
"github.com/timescale/tiger-cli/internal/tiger/config"
11+
)
12+
13+
// alwaysRegisteredTools must be available regardless of read-only mode.
14+
var alwaysRegisteredTools = []string{
15+
toolServiceList,
16+
toolServiceGet,
17+
toolServiceLogs,
18+
toolDBExecuteQuery,
19+
}
20+
21+
// registeredToolNames returns the tool names a server advertises over a real
22+
// client/server session. registerDocsProxy is skipped: it connects to a remote
23+
// server.
24+
func registeredToolNames(t *testing.T, readOnly bool) []string {
25+
t.Helper()
26+
27+
ctx, cancel := context.WithCancel(context.Background())
28+
t.Cleanup(cancel)
29+
30+
s := &Server{
31+
mcpServer: mcp.NewServer(&mcp.Implementation{
32+
Name: ServerName,
33+
Title: serverTitle,
34+
Version: config.Version,
35+
}, nil),
36+
readOnly: readOnly,
37+
}
38+
s.registerServiceTools()
39+
s.registerDatabaseTools()
40+
41+
clientTransport, serverTransport := mcp.NewInMemoryTransports()
42+
43+
serverSession, err := s.mcpServer.Connect(ctx, serverTransport, nil)
44+
if err != nil {
45+
t.Fatalf("server connect: %v", err)
46+
}
47+
t.Cleanup(func() { _ = serverSession.Close() })
48+
49+
client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil)
50+
clientSession, err := client.Connect(ctx, clientTransport, nil)
51+
if err != nil {
52+
t.Fatalf("client connect: %v", err)
53+
}
54+
t.Cleanup(func() { _ = clientSession.Close() })
55+
56+
res, err := clientSession.ListTools(ctx, nil)
57+
if err != nil {
58+
t.Fatalf("list tools: %v", err)
59+
}
60+
61+
names := make([]string, len(res.Tools))
62+
for i, tool := range res.Tools {
63+
names[i] = tool.Name
64+
}
65+
return names
66+
}
67+
68+
func TestReadOnlyToolRegistration(t *testing.T) {
69+
for _, tt := range []struct {
70+
name string
71+
readOnly bool
72+
wantGatedPresent bool
73+
}{
74+
{"read-write registers all tools", false, true},
75+
{"read-only skips gated tools", true, false},
76+
} {
77+
t.Run(tt.name, func(t *testing.T) {
78+
names := registeredToolNames(t, tt.readOnly)
79+
80+
// Read tools and the read-only-safe query tool are always present.
81+
for _, name := range alwaysRegisteredTools {
82+
if !slices.Contains(names, name) {
83+
t.Errorf("expected tool %q to be registered, got %v", name, names)
84+
}
85+
}
86+
// Service-mutating tools are present only in read-write mode.
87+
for _, name := range readOnlyGatedTools {
88+
if got := slices.Contains(names, name); got != tt.wantGatedPresent {
89+
t.Errorf("gated tool %q registered = %v, want %v (got %v)", name, got, tt.wantGatedPresent, names)
90+
}
91+
}
92+
})
93+
}
94+
}

internal/tiger/mcp/service_tools.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const (
3636
toolServiceResize = "service_resize"
3737
toolServiceUpdatePassword = "service_update_password"
3838
toolServiceLogs = "service_logs"
39+
toolDBExecuteQuery = "db_execute_query"
3940
)
4041

4142
// Wait timeout for MCP tool operations
@@ -419,7 +420,7 @@ func (ServiceLogsOutput) Schema() *jsonschema.Schema {
419420
// registerServiceTools registers service management tools with comprehensive schemas and descriptions
420421
func (s *Server) registerServiceTools() {
421422
// service_list
422-
mcp.AddTool(s.mcpServer, &mcp.Tool{
423+
addTool(s, &mcp.Tool{
423424
Name: toolServiceList,
424425
Title: "List Database Services",
425426
Description: "List all database services in your Tiger Cloud project. " +
@@ -434,7 +435,7 @@ func (s *Server) registerServiceTools() {
434435
}, s.handleServiceList)
435436

436437
// service_get
437-
mcp.AddTool(s.mcpServer, &mcp.Tool{
438+
addTool(s, &mcp.Tool{
438439
Name: toolServiceGet,
439440
Title: "Get Service Details",
440441
Description: "Get detailed information for a specific database service. " +
@@ -449,7 +450,7 @@ func (s *Server) registerServiceTools() {
449450
}, s.handleServiceGet)
450451

451452
// service_create
452-
mcp.AddTool(s.mcpServer, &mcp.Tool{
453+
addTool(s, &mcp.Tool{
453454
Name: toolServiceCreate,
454455
Title: "Create Database Service",
455456
Description: `Create a new database service in Tiger Cloud with specified type, compute resources, region, and HA options.
@@ -471,7 +472,7 @@ WARNING: Creates billable resources.`,
471472
}, s.handleServiceCreate)
472473

473474
// service_fork
474-
mcp.AddTool(s.mcpServer, &mcp.Tool{
475+
addTool(s, &mcp.Tool{
475476
Name: toolServiceFork,
476477
Title: "Fork Database Service",
477478
Description: `Fork an existing database service to create a new independent copy.
@@ -499,7 +500,7 @@ WARNING: Creates billable resources.`,
499500
}, s.handleServiceFork)
500501

501502
// service_update_password
502-
mcp.AddTool(s.mcpServer, &mcp.Tool{
503+
addTool(s, &mcp.Tool{
503504
Name: toolServiceUpdatePassword,
504505
Title: "Update Service Password",
505506
Description: "Update master password for 'tsdbadmin' user of a database service. " +
@@ -516,7 +517,7 @@ WARNING: Creates billable resources.`,
516517
}, s.handleServiceUpdatePassword)
517518

518519
// service_start
519-
mcp.AddTool(s.mcpServer, &mcp.Tool{
520+
addTool(s, &mcp.Tool{
520521
Name: toolServiceStart,
521522
Title: "Start Database Service",
522523
Description: `Start a stopped database service.
@@ -534,7 +535,7 @@ This operation starts a service that is currently in a stopped/paused state. The
534535
}, s.handleServiceStart)
535536

536537
// service_stop
537-
mcp.AddTool(s.mcpServer, &mcp.Tool{
538+
addTool(s, &mcp.Tool{
538539
Name: toolServiceStop,
539540
Title: "Stop Database Service",
540541
Description: `Stop a running database service.
@@ -552,7 +553,7 @@ This operation stops a service that is currently running. The service will trans
552553
}, s.handleServiceStop)
553554

554555
// service_resize
555-
mcp.AddTool(s.mcpServer, &mcp.Tool{
556+
addTool(s, &mcp.Tool{
556557
Name: toolServiceResize,
557558
Title: "Resize Database Service",
558559
Description: `Resize a database service by changing its CPU and memory allocation.
@@ -573,7 +574,7 @@ WARNING: Creates billable resource changes. Increasing resources will increase c
573574
}, s.handleServiceResize)
574575

575576
// service_logs
576-
mcp.AddTool(s.mcpServer, &mcp.Tool{
577+
addTool(s, &mcp.Tool{
577578
Name: toolServiceLogs,
578579
Title: "Get Service Logs",
579580
Description: `View logs for a database service.

specs/spec_mcp.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,15 @@ The MCP server will automatically use the CLI's stored authentication and config
6363

6464
When `read_only` is `true` (or `TIGER_READ_ONLY=true`), Tiger refuses any mutating operation:
6565

66-
- **Tiger MCP** — write tools (`service_create`, `service_fork`, `service_start`, `service_stop`, `service_resize`, `service_update_password`) return an error.
66+
- **Tiger MCP** — write tools (`service_create`, `service_fork`, `service_start`, `service_stop`, `service_resize`, `service_update_password`) are not registered, so they don't appear in `tools/list` and can't be called. Handlers still gate on `read_only` as a backstop for mid-session toggling.
6767
- **Tiger CLI** — the matching `tiger service` subcommands (plus `service delete`) return an error.
6868
- **Database sessions**`tiger db connect`, `tiger db connection-string`, and the `db_execute_query` MCP tool open with `tsdb_admin.read_only_connection=true` even without `--read-only`.
6969

7070
Intended for AI agents that should be able to read Tiger Cloud resources without risk of mutation. `tsdb_admin.read_only_connection` is a Tiger Cloud GUC injected as a startup `options` parameter; it activates an immutable read-only connection so writes and DDL are rejected by the server itself and cannot be re-enabled with a `SET` statement.
7171

7272
To toggle: `tiger config set read_only true` / `tiger config unset read_only`.
7373

74-
When read-only mode is enabled, the MCP server includes a warning in its `initialize` response `instructions` field listing the gated tools and asking the LLM to inform the user before gathering inputs for them. The instructions are read at server start; if the user toggles `read_only` mid-session, the warning is stale until the MCP client restarts (the gate and the GUC injection are both unaffected because handlers reload config per call).
74+
When read-only mode is enabled, the `initialize` response `instructions` announce that read-only mode is on but describe only the read tools, never naming the gated write tools. Both tool registration and instructions are evaluated once at server start, so toggling `read_only` mid-session leaves them stale until the MCP client restarts (the handler-level gate and GUC injection are unaffected, since handlers reload config per call).
7575

7676
### CLI MCP Commands
7777

0 commit comments

Comments
 (0)