From e5cc00f143c02cb97f9d8119ec1889e5a7c02605 Mon Sep 17 00:00:00 2001 From: Akihiro Suda Date: Wed, 1 Oct 2025 17:08:59 +0900 Subject: [PATCH 1/2] mcp: use StructuredContent Fix issue 4070 Fix issue 4080 Signed-off-by: Akihiro Suda --- pkg/mcp/msi/filesystem.go | 16 ++++++++++ pkg/mcp/toolset/filesystem.go | 55 ++++++++++++++++++++--------------- pkg/mcp/toolset/shell.go | 13 +++------ 3 files changed, 52 insertions(+), 32 deletions(-) diff --git a/pkg/mcp/msi/filesystem.go b/pkg/mcp/msi/filesystem.go index f15321cc1e3..ba4e6dfbfe1 100644 --- a/pkg/mcp/msi/filesystem.go +++ b/pkg/mcp/msi/filesystem.go @@ -42,6 +42,10 @@ var ReadFile = &mcp.Tool{ Description: `Reads and returns the content of a specified file.`, } +type ReadFileResult struct { + Content string `json:"content" jsonschema:"The content of the file."` +} + type ReadFileParams struct { Path string `json:"path" jsonschema:"The absolute path to the file to read."` // TODO: Offset *int `json:"offset,omitempty" jsonschema:"For text files, the 0-based line number to start reading from. Requires limit to be set."` @@ -53,6 +57,10 @@ var WriteFile = &mcp.Tool{ Description: `Writes content to a specified file. If the file exists, it will be overwritten. If the file doesn't exist, it (and any necessary parent directories) will be created.`, } +type WriteFileResult struct { + // Empty for now +} + type WriteFileParams struct { Path string `json:"path" jsonschema:"The absolute path to the file to write to."` Content string `json:"content" jsonschema:"The content to write into the file."` @@ -69,6 +77,10 @@ type GlobParams struct { // TODO: CaseSensitive bool `json:"case_sensitive,omitempty" jsonschema:": Whether the search should be case-sensitive. Defaults to false."` } +type GlobResult struct { + Matches []string `json:"matches" jsonschema:"A list of absolute file paths that match the provided glob pattern."` +} + var SearchFileContent = &mcp.Tool{ Name: "search_file_content", Description: `Searches for a regular expression pattern within the content of files in a specified directory. Internally calls 'git grep -n --no-index'.`, @@ -80,4 +92,8 @@ type SearchFileContentParams struct { Include *string `json:"include,omitempty" jsonschema:"A glob pattern to filter which files are searched (e.g., '*.js', 'src/**/*.{ts,tsx}'). If omitted, searches most files (respecting common ignores)."` } +type SearchFileContentResult struct { + GitGrepOutput string `json:"git_grep_output" jsonschema:"The raw output from the 'git grep -n --no-index' command, containing matching lines with filenames and line numbers."` +} + // TODO: implement Replace diff --git a/pkg/mcp/toolset/filesystem.go b/pkg/mcp/toolset/filesystem.go index 0b2bc9495e7..61962d75809 100644 --- a/pkg/mcp/toolset/filesystem.go +++ b/pkg/mcp/toolset/filesystem.go @@ -5,7 +5,6 @@ package toolset import ( "context" - "encoding/json" "errors" "io" "os" @@ -19,7 +18,7 @@ import ( func (ts *ToolSet) ListDirectory(ctx context.Context, _ *mcp.CallToolRequest, args msi.ListDirectoryParams, -) (*mcp.CallToolResult, any, error) { +) (*mcp.CallToolResult, *msi.ListDirectoryResult, error) { if ts.inst == nil { return nil, nil, errors.New("instance not registered") } @@ -31,7 +30,7 @@ func (ts *ToolSet) ListDirectory(ctx context.Context, if err != nil { return nil, nil, err } - res := msi.ListDirectoryResult{ + res := &msi.ListDirectoryResult{ Entries: make([]msi.ListDirectoryResultEntry, len(guestEnts)), } for i, f := range guestEnts { @@ -41,18 +40,14 @@ func (ts *ToolSet) ListDirectory(ctx context.Context, res.Entries[i].ModTime = ptr.Of(f.ModTime()) res.Entries[i].IsDir = ptr.Of(f.IsDir()) } - resJ, err := json.Marshal(res) - if err != nil { - return nil, nil, err - } return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: string(resJ)}}, - }, nil, nil + StructuredContent: res, + }, res, nil } func (ts *ToolSet) ReadFile(_ context.Context, _ *mcp.CallToolRequest, args msi.ReadFileParams, -) (*mcp.CallToolResult, any, error) { +) (*mcp.CallToolResult, *msi.ReadFileResult, error) { if ts.inst == nil { return nil, nil, errors.New("instance not registered") } @@ -71,17 +66,20 @@ func (ts *ToolSet) ReadFile(_ context.Context, if err != nil { return nil, nil, err } + res := &msi.ReadFileResult{ + Content: string(b), + } return &mcp.CallToolResult{ // Gemini: // For text files: The file content, potentially prefixed with a truncation message // (e.g., [File content truncated: showing lines 1-100 of 500 total lines...]\nActual file content...). - Content: []mcp.Content{&mcp.TextContent{Text: string(b)}}, - }, nil, nil + StructuredContent: res, + }, res, nil } func (ts *ToolSet) WriteFile(_ context.Context, _ *mcp.CallToolRequest, args msi.WriteFileParams, -) (*mcp.CallToolResult, any, error) { +) (*mcp.CallToolResult, *msi.WriteFileResult, error) { if ts.inst == nil { return nil, nil, errors.New("instance not registered") } @@ -98,17 +96,18 @@ func (ts *ToolSet) WriteFile(_ context.Context, if err != nil { return nil, nil, err } + res := &msi.WriteFileResult{} return &mcp.CallToolResult{ // Gemini: // A success message, e.g., `Successfully overwrote file: /path/to/your/file.txt` // or `Successfully created and wrote to new file: /path/to/new/file.txt.` - Content: []mcp.Content{&mcp.TextContent{Text: "OK"}}, - }, nil, nil + StructuredContent: res, + }, res, nil } func (ts *ToolSet) Glob(_ context.Context, _ *mcp.CallToolRequest, args msi.GlobParams, -) (*mcp.CallToolResult, any, error) { +) (*mcp.CallToolResult, *msi.GlobResult, error) { if ts.inst == nil { return nil, nil, errors.New("instance not registered") } @@ -128,20 +127,19 @@ func (ts *ToolSet) Glob(_ context.Context, if err != nil { return nil, nil, err } - resJ, err := json.Marshal(matches) - if err != nil { - return nil, nil, err + res := &msi.GlobResult{ + Matches: matches, } return &mcp.CallToolResult{ // Gemini: // A message like: Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts... - Content: []mcp.Content{&mcp.TextContent{Text: string(resJ)}}, - }, nil, nil + StructuredContent: res, + }, res, nil } func (ts *ToolSet) SearchFileContent(ctx context.Context, req *mcp.CallToolRequest, args msi.SearchFileContentParams, -) (*mcp.CallToolResult, any, error) { +) (*mcp.CallToolResult, *msi.SearchFileContentResult, error) { if ts.inst == nil { return nil, nil, errors.New("instance not registered") } @@ -159,7 +157,18 @@ func (ts *ToolSet) SearchFileContent(ctx context.Context, if args.Include != nil && *args.Include != "" { guestPath = path.Join(guestPath, *args.Include) } - return ts.RunShellCommand(ctx, req, msi.RunShellCommandParams{ + cmdToolRes, cmdRes, err := ts.RunShellCommand(ctx, req, msi.RunShellCommandParams{ Command: []string{"git", "grep", "-n", "--no-index", args.Pattern, guestPath}, }) + if err != nil { + return cmdToolRes, nil, err + } + res := &msi.SearchFileContentResult{ + GitGrepOutput: cmdRes.Stdout, + } + return &mcp.CallToolResult{ + // Gemini: + // A message like: Found 10 matching lines for regex "function\\s+myFunction" in directory src:\nsrc/file1.js:10:function myFunction() {...}\nsrc/subdir/file2.ts:45: function myFunction(param) {...}... + StructuredContent: res, + }, res, nil } diff --git a/pkg/mcp/toolset/shell.go b/pkg/mcp/toolset/shell.go index eb00cbb5cf3..9de4e963e6b 100644 --- a/pkg/mcp/toolset/shell.go +++ b/pkg/mcp/toolset/shell.go @@ -6,7 +6,6 @@ package toolset import ( "bytes" "context" - "encoding/json" "errors" "os/exec" @@ -18,7 +17,7 @@ import ( func (ts *ToolSet) RunShellCommand(ctx context.Context, _ *mcp.CallToolRequest, args msi.RunShellCommandParams, -) (*mcp.CallToolResult, any, error) { +) (*mcp.CallToolResult, *msi.RunShellCommandResult, error) { if ts.inst == nil { return nil, nil, errors.New("instance not registered") } @@ -33,7 +32,7 @@ func (ts *ToolSet) RunShellCommand(ctx context.Context, cmd.Stdout = &stdout cmd.Stderr = &stderr cmdErr := cmd.Run() - res := msi.RunShellCommandResult{ + res := &msi.RunShellCommandResult{ Stdout: stdout.String(), Stderr: stderr.String(), } @@ -45,11 +44,7 @@ func (ts *ToolSet) RunShellCommand(ctx context.Context, res.ExitCode = ptr.Of(st.ExitCode()) } } - resJ, err := json.Marshal(res) - if err != nil { - return nil, nil, err - } return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: string(resJ)}}, - }, nil, nil + StructuredContent: res, + }, res, nil } From 9e735aa05879dc98e1cdf1684311171749bdd638 Mon Sep 17 00:00:00 2001 From: Akihiro Suda Date: Wed, 1 Oct 2025 17:35:50 +0900 Subject: [PATCH 2/2] mcp: search_file_content: fix "path is empty" error Signed-off-by: Akihiro Suda --- pkg/mcp/toolset/filesystem.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/mcp/toolset/filesystem.go b/pkg/mcp/toolset/filesystem.go index 61962d75809..e60032b24aa 100644 --- a/pkg/mcp/toolset/filesystem.go +++ b/pkg/mcp/toolset/filesystem.go @@ -158,7 +158,8 @@ func (ts *ToolSet) SearchFileContent(ctx context.Context, guestPath = path.Join(guestPath, *args.Include) } cmdToolRes, cmdRes, err := ts.RunShellCommand(ctx, req, msi.RunShellCommandParams{ - Command: []string{"git", "grep", "-n", "--no-index", args.Pattern, guestPath}, + Command: []string{"git", "grep", "-n", "--no-index", args.Pattern, guestPath}, + Directory: pathStr, // Directory must be always set }) if err != nil { return cmdToolRes, nil, err