Skip to content

Commit f23499f

Browse files
authored
fix: harden MCP statement output surface (#777)
Summary: - Reject backslash meta commands from MCP execute_statement. - Cap MCP statement output capture and return an explicit truncation marker. - Route progress bars only to the StreamManager TTY, disabling them when no TTY is available. - Update MCP tool text to describe captured output rather than ASCII-only tables. Validation: - make check - GitHub checks: lint, test, race, govulncheck, cross-compile, readme-sync - Gemini reported no feedback; no unresolved review threads remain.
1 parent ec3190b commit f23499f

4 files changed

Lines changed: 217 additions & 57 deletions

File tree

internal/mycli/cli_mcp.go

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,48 @@ import (
3030
"github.com/samber/lo"
3131
)
3232

33+
const mcpMaxOutputBytes = 1 << 20
34+
3335
// ExecuteStatementArgs represents the arguments for the execute_statement tool
3436
type ExecuteStatementArgs struct {
3537
Statement string `json:"statement" jsonschema:"Valid spanner-mycli statement to execute"`
3638
}
3739

40+
type mcpOutputCapture struct {
41+
limit int
42+
builder strings.Builder
43+
truncated bool
44+
}
45+
46+
func newMCPOutputCapture(limit int) *mcpOutputCapture {
47+
if limit < 0 {
48+
limit = 0
49+
}
50+
return &mcpOutputCapture{limit: limit}
51+
}
52+
53+
func (c *mcpOutputCapture) Write(p []byte) (int, error) {
54+
if c.truncated {
55+
return len(p), nil
56+
}
57+
58+
if remaining := c.limit - c.builder.Len(); remaining > 0 {
59+
if len(p) <= remaining {
60+
_, _ = c.builder.Write(p)
61+
return len(p), nil
62+
}
63+
_, _ = c.builder.Write(p[:remaining])
64+
}
65+
66+
c.truncated = true
67+
fmt.Fprintf(&c.builder, "\n\n[spanner-mycli MCP output truncated after %d bytes]\n", c.limit)
68+
return len(p), nil
69+
}
70+
71+
func (c *mcpOutputCapture) String() string {
72+
return c.builder.String()
73+
}
74+
3875
// executeStatementHandler handles the execute_statement tool
3976
func executeStatementHandler(cli *Cli) func(context.Context, *mcp.CallToolRequest, ExecuteStatementArgs) (*mcp.CallToolResult, any, error) {
4077
// Mutex to protect concurrent access to cli.executeStatement
@@ -72,11 +109,22 @@ func executeStatementHandler(cli *Cli) func(context.Context, *mcp.CallToolReques
72109
}, nil, nil
73110
}
74111

75-
// Create a string builder to capture the output
76-
var sb strings.Builder
112+
if _, ok := stmt.(MetaCommandStatement); ok {
113+
slog.Debug("MCP request rejected meta command",
114+
"duration", time.Since(start))
115+
// Per MCP spec, return execution errors as tool output, not protocol errors.
116+
return &mcp.CallToolResult{
117+
Content: []mcp.Content{
118+
&mcp.TextContent{Text: "ERROR: meta commands are not supported by MCP execute_statement"},
119+
},
120+
}, nil, nil
121+
}
122+
123+
// Capture output without allowing one MCP call to grow memory unbounded.
124+
output := newMCPOutputCapture(mcpMaxOutputBytes)
77125

78-
// Execute the statement with the string builder as the output
79-
_, err = cli.executeStatement(ctx, stmt, false, statement, &sb)
126+
// Execute the statement with the capped capture as the output
127+
_, err = cli.executeStatement(ctx, stmt, false, statement, output)
80128
if err != nil {
81129
slog.Debug("MCP execution failed",
82130
"error", err.Error(),
@@ -89,17 +137,18 @@ func executeStatementHandler(cli *Cli) func(context.Context, *mcp.CallToolReques
89137
}, nil, nil
90138
}
91139

140+
text := output.String()
92141
result := &mcp.CallToolResult{
93142
Content: []mcp.Content{
94-
&mcp.TextContent{Text: sb.String()},
143+
&mcp.TextContent{Text: text},
95144
},
96145
}
97146

98147
// Log response if debug logging is enabled
99148
slog.Debug("MCP response sent",
100-
"output_length", len(sb.String()),
149+
"output_length", len(text),
101150
"duration", time.Since(start),
102-
"output_preview", tabwrap.Truncate(sb.String(), 100, "..."))
151+
"output_preview", tabwrap.Truncate(text, 100, "..."))
103152

104153
return result, nil, nil
105154
}
@@ -121,7 +170,9 @@ Supports:
121170
- GQL queries for graph-based data
122171
- Client-side commands: HELP, SHOW TABLES/COLUMNS/INDEX, SET variables, USE database
123172
124-
The result is returned as an ASCII-formatted table. When displaying in Markdown, wrap the output in a code block.
173+
Backslash meta commands are not supported by this MCP tool.
174+
175+
The result is returned as captured text using the active CLI output format (table by default). When displaying in Markdown, wrap the output in a code block.
125176
126177
Use "HELP" command to see all available statements and their syntax.`)
127178

internal/mycli/execute_ddl.go

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@ var replacerForProgress = strings.NewReplacer(
4141
"\t", " ",
4242
)
4343

44+
func newProgressWithTTY(ctx context.Context, session *Session) *mpb.Progress {
45+
if session == nil || session.systemVariables == nil || session.systemVariables.StreamManager == nil {
46+
return nil
47+
}
48+
ttyStream := session.systemVariables.StreamManager.GetTtyStream()
49+
if ttyStream == nil {
50+
return nil
51+
}
52+
return mpb.NewWithContext(ctx, mpb.WithOutput(ttyStream))
53+
}
54+
4455
func executeDdlStatements(ctx context.Context, session *Session, ddls []string) (*Result, error) {
4556
if len(ddls) == 0 {
4657
return &Result{
@@ -64,19 +75,21 @@ func executeDdlStatements(ctx context.Context, session *Session, ddls []string)
6475
}
6576
}
6677
if session.systemVariables.Display.EnableProgressBar {
67-
p = mpb.NewWithContext(ctx)
68-
69-
for _, ddl := range ddls {
70-
bar := p.AddBar(int64(100),
71-
mpb.PrependDecorators(
72-
decor.Spinner(nil, decor.WCSyncSpaceR),
73-
decor.Name(tabwrap.Truncate(replacerForProgress.Replace(ddl), 40, "..."), decor.WCSyncSpaceR),
74-
decor.Percentage(decor.WCSyncSpace),
75-
decor.Elapsed(decor.ET_STYLE_MMSS, decor.WCSyncSpace)),
76-
mpb.BarRemoveOnComplete(),
77-
)
78-
bar.EnableTriggerComplete()
79-
bars = append(bars, bar)
78+
p = newProgressWithTTY(ctx, session)
79+
80+
if p != nil {
81+
for _, ddl := range ddls {
82+
bar := p.AddBar(int64(100),
83+
mpb.PrependDecorators(
84+
decor.Spinner(nil, decor.WCSyncSpaceR),
85+
decor.Name(tabwrap.Truncate(replacerForProgress.Replace(ddl), 40, "..."), decor.WCSyncSpaceR),
86+
decor.Percentage(decor.WCSyncSpace),
87+
decor.Elapsed(decor.ET_STYLE_MMSS, decor.WCSyncSpace)),
88+
mpb.BarRemoveOnComplete(),
89+
)
90+
bar.EnableTriggerComplete()
91+
bars = append(bars, bar)
92+
}
8093
}
8194
}
8295

internal/mycli/output_context_test.go

Lines changed: 119 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import (
1818
"bytes"
1919
"context"
2020
"io"
21-
"runtime"
2221
"strings"
2322
"testing"
2423

@@ -170,38 +169,133 @@ func TestMCPHandler_streamedFormatDoesNotLeakToGlobalStream(t *testing.T) {
170169
}
171170
}
172171

173-
// TestMCPHandler_shellMetaCommandDoesNotLeakToGlobalStream pins that \!
174-
// shell command stdout goes to the per-statement writer, not the global
175-
// stream: the MCP handler can parse and execute meta commands, so shell
176-
// output written to the StreamManager writer would corrupt the JSON-RPC
177-
// stream under --mcp.
178-
func TestMCPHandler_shellMetaCommandDoesNotLeakToGlobalStream(t *testing.T) {
172+
func TestMCPHandler_rejectsMetaCommands(t *testing.T) {
179173
t.Parallel()
180-
if runtime.GOOS == "windows" {
181-
t.Skip("shell command syntax differs on Windows")
174+
175+
tests := []struct {
176+
name string
177+
statement string
178+
skipSystemCommand bool
179+
}{
180+
{
181+
name: "shell command",
182+
statement: `\! echo mcp-shell-probe`,
183+
},
184+
{
185+
name: "shell command with skip system command",
186+
statement: `\! echo mcp-shell-probe`,
187+
skipSystemCommand: true,
188+
},
189+
{
190+
name: "output redirect",
191+
statement: `\o out.log`,
192+
},
193+
{
194+
name: "tee output",
195+
statement: `\T out.log`,
196+
},
182197
}
183198

184-
var global bytes.Buffer
185-
session := newDetachedTestSession(&global)
186-
// ShellMetaCommand is not detached-compatible; the shell command itself
187-
// never touches the (nil) client.
188-
session.mode = DatabaseConnected
189-
cli := &Cli{
190-
SessionHandler: NewSessionHandler(session),
191-
SystemVariables: session.systemVariables,
199+
for _, tt := range tests {
200+
t.Run(tt.name, func(t *testing.T) {
201+
t.Parallel()
202+
203+
var global bytes.Buffer
204+
session := newDetachedTestSession(&global)
205+
session.systemVariables.Config.SkipSystemCommand = tt.skipSystemCommand
206+
cli := &Cli{
207+
SessionHandler: NewSessionHandler(session),
208+
SystemVariables: session.systemVariables,
209+
}
210+
211+
handler := executeStatementHandler(cli)
212+
result, _, err := handler(context.Background(), nil, ExecuteStatementArgs{Statement: tt.statement})
213+
if err != nil {
214+
t.Fatalf("handler: %v", err)
215+
}
216+
217+
text := mcpResultText(t, result)
218+
if !strings.Contains(text, "ERROR: meta commands are not supported by MCP execute_statement") {
219+
t.Errorf("tool result = %q, want meta-command rejection", text)
220+
}
221+
if strings.Contains(text, "mcp-shell-probe") {
222+
t.Errorf("tool result = %q, shell command appears to have executed", text)
223+
}
224+
if global.Len() != 0 {
225+
t.Errorf("StreamManager writer got %q, want empty (would corrupt the MCP protocol stream)", global.String())
226+
}
227+
})
192228
}
229+
}
193230

194-
handler := executeStatementHandler(cli)
195-
result, _, err := handler(context.Background(), nil, ExecuteStatementArgs{Statement: `\! echo mcp-shell-probe`})
196-
if err != nil {
197-
t.Fatalf("handler: %v", err)
231+
func TestMCPOutputCaptureTruncates(t *testing.T) {
232+
t.Parallel()
233+
234+
tests := []struct {
235+
name string
236+
limit int
237+
chunks []string
238+
wantPrefix string
239+
notWant string
240+
}{
241+
{
242+
name: "partial chunk at limit",
243+
limit: 8,
244+
chunks: []string{"12345", "67890"},
245+
wantPrefix: "12345678",
246+
notWant: "90",
247+
},
248+
{
249+
name: "discard writes after truncation",
250+
limit: 10,
251+
chunks: []string{"12345", "67890", "overflow", "more"},
252+
wantPrefix: "1234567890",
253+
notWant: "overflow",
254+
},
198255
}
199256

200-
text := mcpResultText(t, result)
201-
if !strings.Contains(text, "mcp-shell-probe") {
202-
t.Errorf("tool result %q does not contain shell output", text)
257+
for _, tt := range tests {
258+
t.Run(tt.name, func(t *testing.T) {
259+
t.Parallel()
260+
261+
capture := newMCPOutputCapture(tt.limit)
262+
for _, chunk := range tt.chunks {
263+
n, err := capture.Write([]byte(chunk))
264+
if err != nil {
265+
t.Fatalf("Write(%q): %v", chunk, err)
266+
}
267+
if n != len(chunk) {
268+
t.Fatalf("Write(%q) = %d, want %d", chunk, n, len(chunk))
269+
}
270+
}
271+
272+
got := capture.String()
273+
if !strings.HasPrefix(got, tt.wantPrefix) {
274+
t.Errorf("capture prefix = %q, want %q", got, tt.wantPrefix)
275+
}
276+
if !strings.Contains(got, "spanner-mycli MCP output truncated after") {
277+
t.Errorf("capture = %q, want truncation marker", got)
278+
}
279+
if strings.Contains(got, tt.notWant) {
280+
t.Errorf("capture = %q, should not contain discarded content %q", got, tt.notWant)
281+
}
282+
})
283+
}
284+
}
285+
286+
func TestProgressWithTTYDisabledWithoutTTY(t *testing.T) {
287+
t.Parallel()
288+
289+
var global bytes.Buffer
290+
session := newDetachedTestSession(&global)
291+
session.systemVariables.Display.EnableProgressBar = true
292+
293+
progress := newProgressWithTTY(context.Background(), session)
294+
if progress != nil {
295+
progress.Wait()
296+
t.Fatal("newProgressWithTTY returned a progress container without a TTY")
203297
}
204298
if global.Len() != 0 {
205-
t.Errorf("StreamManager writer got %q, want empty (would corrupt the MCP protocol stream)", global.String())
299+
t.Errorf("StreamManager writer got %q, want empty", global.String())
206300
}
207301
}

internal/mycli/statements.go

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -345,17 +345,19 @@ func (s *ShowOperationStatement) executeSyncMode(ctx context.Context, session *S
345345
operationDesc := s.getOperationDescription(op)
346346

347347
if session.systemVariables.Display.EnableProgressBar {
348-
p = mpb.NewWithContext(ctx)
349-
bar = p.AddBar(int64(100),
350-
mpb.PrependDecorators(
351-
decor.Spinner(nil, decor.WCSyncSpaceR),
352-
decor.Name(tabwrap.Truncate(replacerForProgress.Replace(operationDesc), 40, "..."), decor.WCSyncSpaceR),
353-
decor.Percentage(decor.WCSyncSpace),
354-
decor.Elapsed(decor.ET_STYLE_MMSS, decor.WCSyncSpace)),
355-
mpb.BarRemoveOnComplete(),
356-
)
357-
bar.EnableTriggerComplete()
358-
defer teardown()
348+
p = newProgressWithTTY(ctx, session)
349+
if p != nil {
350+
bar = p.AddBar(int64(100),
351+
mpb.PrependDecorators(
352+
decor.Spinner(nil, decor.WCSyncSpaceR),
353+
decor.Name(tabwrap.Truncate(replacerForProgress.Replace(operationDesc), 40, "..."), decor.WCSyncSpaceR),
354+
decor.Percentage(decor.WCSyncSpace),
355+
decor.Elapsed(decor.ET_STYLE_MMSS, decor.WCSyncSpace)),
356+
mpb.BarRemoveOnComplete(),
357+
)
358+
bar.EnableTriggerComplete()
359+
defer teardown()
360+
}
359361
}
360362

361363
// Update progress bar with initial status

0 commit comments

Comments
 (0)