Skip to content

Commit 20051cf

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 e8c0afb commit 20051cf

6 files changed

Lines changed: 143 additions & 25 deletions

File tree

.claude/settings.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"permissions": {
3+
"deny": [
4+
"Bash(tiger config set:*)",
5+
"Bash(go run ./cmd/tiger config set:*)",
6+
"Bash(./bin/tiger config set:*)",
7+
"Bash(tiger config unset:*)",
8+
"Bash(go run ./cmd/tiger config unset:*)",
9+
"Bash(./bin/tiger config unset:*)"
10+
]
11+
}
12+
}

internal/tiger/mcp/db_tools.go

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

108108
// registerDatabaseTools registers database operation tools with comprehensive schemas and descriptions
109109
func (s *Server) registerDatabaseTools() {
110-
mcp.AddTool(s.mcpServer, &mcp.Tool{
111-
Name: "db_execute_query",
110+
addTool(s, &mcp.Tool{
111+
Name: toolDBExecuteQuery,
112112
Title: "Execute SQL Query",
113113
Description: `Execute SQL queries against a service database.
114114

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/server.go

Lines changed: 24 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,34 @@ 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: describe only the registered read tools so the client never
55+
// learns the service-mutating tools exist.
56+
return intro +
57+
"Use it to list and inspect services, fetch service logs, run read-only SQL queries (writes and DDL are rejected), and search Tiger documentation."
4958
}
5059

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

6069
server := &Server{
6170
mcpServer: mcpServer,
71+
readOnly: cfg != nil && cfg.ReadOnly,
6272
}
6373

6474
// 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.

0 commit comments

Comments
 (0)