Skip to content

Commit cacd2bd

Browse files
committed
feat(spanner): add native read-only transactional support
1 parent efeaaa2 commit cacd2bd

5 files changed

Lines changed: 107 additions & 1 deletion

File tree

docs/SPANNER_README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export SPANNER_PROJECT="<your-gcp-project-id>"
6767
export SPANNER_INSTANCE="<your-spanner-instance-id>"
6868
export SPANNER_DATABASE="<your-spanner-database-id>"
6969
export SPANNER_DIALECT="googlesql" # Optional: "googlesql" or "postgresql". Defaults to "googlesql".
70+
export SPANNER_READONLY="true" # Optional: Restricts tools and executes queries on read-only endpoints.
7071
```
7172

7273
Add the following configuration to your MCP client (e.g., `settings.json` for Gemini CLI, `mcp_config.json` for Antigravity):

internal/prebuiltconfigs/tools/spanner.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,24 @@ project: ${SPANNER_PROJECT}
1919
instance: ${SPANNER_INSTANCE}
2020
database: ${SPANNER_DATABASE}
2121
dialect: ${SPANNER_DIALECT:googlesql}
22+
readonly: ${SPANNER_READONLY:false}
2223
---
2324
kind: tool
2425
name: execute_sql
2526
type: spanner-execute-sql
2627
source: spanner-source
2728
description: Use this tool to execute DML SQL. Please use the ${SPANNER_DIALECT:googlesql} interface for Spanner.
29+
annotations:
30+
readOnlyHint: false
2831
---
2932
kind: tool
3033
name: execute_sql_dql
3134
type: spanner-execute-sql
3235
source: spanner-source
3336
description: Use this tool to execute DQL SQL. Please use the ${SPANNER_DIALECT:googlesql} interface for Spanner.
3437
readOnly: true
38+
annotations:
39+
readOnlyHint: true
3540
---
3641
kind: tool
3742
name: list_tables

internal/sources/spanner/spanner.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ type Config struct {
5757
Dialect sources.Dialect `yaml:"dialect" validate:"required"`
5858
Database string `yaml:"database" validate:"required"`
5959
UseClientOAuth bool `yaml:"useClientOAuth"`
60+
ReadOnly bool `yaml:"readonly"`
6061
}
6162

6263
func (r Config) SourceConfigType() string {
@@ -102,6 +103,10 @@ func (s *Source) ToConfig() sources.SourceConfig {
102103
return s.Config
103104
}
104105

106+
func (s *Source) IsReadOnlyMode() bool {
107+
return s.ReadOnly
108+
}
109+
105110
func (s *Source) SpannerClient() *spanner.Client {
106111
return s.Client
107112
}
@@ -174,7 +179,7 @@ func (s *Source) RunSQL(ctx context.Context, readOnly bool, statement string, pa
174179
stmt.Params = params
175180
}
176181

177-
if readOnly {
182+
if readOnly || s.ReadOnly {
178183
iter := s.SpannerClient().Single().Query(ctx, stmt)
179184
results, opErr = processRows(iter)
180185
} else {

internal/tools/spanner/spannerexecutesql/spannerexecutesql.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ func (cfg Config) Initialize(context.Context) (tools.Tool, error) {
6868
return nil, fmt.Errorf("description is required for tool %q", cfg.Name)
6969
}
7070

71+
if cfg.Annotations != nil && cfg.Annotations.ReadOnlyHint != nil {
72+
if cfg.ReadOnly != *cfg.Annotations.ReadOnlyHint {
73+
return nil, fmt.Errorf("configuration conflict in tool %q: legacy readOnly=%v does not match readOnlyHint=%v", cfg.Name, cfg.ReadOnly, *cfg.Annotations.ReadOnlyHint)
74+
}
75+
}
76+
7177
sqlParameter := parameters.NewStringParameter("sql", "The sql to execute.")
7278
params := parameters.Parameters{sqlParameter}
7379

internal/tools/spanner/spannerexecutesql/spannerexecutesql_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package spannerexecutesql_test
1616

1717
import (
18+
"context"
1819
"testing"
1920

2021
"github.com/google/go-cmp/cmp"
@@ -94,3 +95,91 @@ func TestParseFromYamlExecuteSql(t *testing.T) {
9495
}
9596

9697
}
98+
99+
func TestInitialize_ReadOnlyValidation(t *testing.T) {
100+
ctx := context.Background()
101+
102+
ptr := func(b bool) *bool { return &b }
103+
104+
tcs := []struct {
105+
desc string
106+
cfg spannerexecutesql.Config
107+
wantErr bool
108+
errContains string
109+
}{
110+
{
111+
desc: "no conflict - both true",
112+
cfg: spannerexecutesql.Config{
113+
ConfigBase: tools.ConfigBase{Name: "test-tool", Description: "desc"},
114+
ReadOnly: true,
115+
Annotations: &tools.ToolAnnotations{ReadOnlyHint: ptr(true)},
116+
},
117+
wantErr: false,
118+
},
119+
{
120+
desc: "no conflict - both false",
121+
cfg: spannerexecutesql.Config{
122+
ConfigBase: tools.ConfigBase{Name: "test-tool", Description: "desc"},
123+
ReadOnly: false,
124+
Annotations: &tools.ToolAnnotations{ReadOnlyHint: ptr(false)},
125+
},
126+
wantErr: false,
127+
},
128+
{
129+
desc: "no conflict - readOnlyHint nil",
130+
cfg: spannerexecutesql.Config{
131+
ConfigBase: tools.ConfigBase{Name: "test-tool", Description: "desc"},
132+
ReadOnly: true,
133+
Annotations: &tools.ToolAnnotations{ReadOnlyHint: nil},
134+
},
135+
wantErr: false,
136+
},
137+
{
138+
desc: "conflict - readOnly false, readOnlyHint true",
139+
cfg: spannerexecutesql.Config{
140+
ConfigBase: tools.ConfigBase{Name: "test-tool", Description: "desc"},
141+
ReadOnly: false,
142+
Annotations: &tools.ToolAnnotations{ReadOnlyHint: ptr(true)},
143+
},
144+
wantErr: true,
145+
errContains: "configuration conflict in tool \"test-tool\"",
146+
},
147+
{
148+
desc: "conflict - readOnly true, readOnlyHint false",
149+
cfg: spannerexecutesql.Config{
150+
ConfigBase: tools.ConfigBase{Name: "test-tool", Description: "desc"},
151+
ReadOnly: true,
152+
Annotations: &tools.ToolAnnotations{ReadOnlyHint: ptr(false)},
153+
},
154+
wantErr: true,
155+
errContains: "configuration conflict in tool \"test-tool\"",
156+
},
157+
}
158+
159+
for _, tc := range tcs {
160+
t.Run(tc.desc, func(t *testing.T) {
161+
_, err := tc.cfg.Initialize(ctx)
162+
if tc.wantErr {
163+
if err == nil {
164+
t.Fatalf("expected error, got nil")
165+
}
166+
// Use manual substring check since strings import might not be present
167+
errStr := err.Error()
168+
found := false
169+
for i := 0; i <= len(errStr)-len(tc.errContains); i++ {
170+
if errStr[i:i+len(tc.errContains)] == tc.errContains {
171+
found = true
172+
break
173+
}
174+
}
175+
if !found {
176+
t.Errorf("expected error to contain %q, got: %v", tc.errContains, err)
177+
}
178+
} else {
179+
if err != nil {
180+
t.Fatalf("unexpected error: %v", err)
181+
}
182+
}
183+
})
184+
}
185+
}

0 commit comments

Comments
 (0)