From 696c8bc1b5474413f2f036b740721d49765ec9df Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Fri, 28 Nov 2025 15:14:18 +0200 Subject: [PATCH 1/7] make json rpc 2.0 call based on cli args - basic sse resposne parsing - remove zero / nil arguments before making the request --- cmd/src/mcp.go | 107 ++++++++++++++++++++++++++++++++++----- internal/mcp/mcp_args.go | 19 ++++++- 2 files changed, 113 insertions(+), 13 deletions(-) diff --git a/cmd/src/mcp.go b/cmd/src/mcp.go index 458ff0ce05..6fce1c0bdd 100644 --- a/cmd/src/mcp.go +++ b/cmd/src/mcp.go @@ -1,13 +1,23 @@ package main import ( + "bytes" + "context" + "encoding/json" "flag" "fmt" + "io" + "net/http" "strings" + "github.com/sourcegraph/src-cli/internal/api" "github.com/sourcegraph/src-cli/internal/mcp" + + "github.com/sourcegraph/sourcegraph/lib/errors" ) +const McpPath = ".api/mcp/v1" + func init() { flagSet := flag.NewFlagSet("mcp", flag.ExitOnError) commands = append(commands, &command{ @@ -44,37 +54,110 @@ func mcpMain(args []string) error { if !ok { return fmt.Errorf("tool definition for %q not found - run src mcp list-tools to see a list of available tools", subcmd) } - return handleMcpTool(tool, args[1:]) -} -func handleMcpTool(tool *mcp.ToolDef, args []string) error { - fs, vars, err := mcp.BuildArgFlagSet(tool) + flagArgs := args[1:] // skip subcommand name + if len(args) > 1 && args[1] == "schema" { + return printSchemas(tool) + } + + flags, vars, err := mcp.BuildToolFlagSet(tool) if err != nil { return err } + if err := flags.Parse(flagArgs); err != nil { + return err + } + sanitizeFlagValues(vars) + + if err := validateToolArgs(tool.InputSchema, args, vars); err != nil { + return err + } - if err := fs.Parse(args); err != nil { + apiClient := cfg.apiClient(nil, flags.Output()) + return handleMcpTool(context.Background(), apiClient, tool, vars) +} + +func printSchemas(tool *mcp.ToolDef) error { + input, err := json.MarshalIndent(tool.InputSchema, "", " ") + if err != nil { + return err + } + output, err := json.MarshalIndent(tool.OutputSchema, "", " ") + if err != nil { return err } - inputSchema := tool.InputSchema + fmt.Printf("Input:\n%v\nOutput:\n%v\n", string(input), string(output)) + return nil +} +func validateToolArgs(inputSchema mcp.Schema, args []string, vars map[string]any) error { for _, reqName := range inputSchema.Required { if vars[reqName] == nil { - return fmt.Errorf("no value provided for required flag --%s", reqName) + return errors.Newf("no value provided for required flag --%s", reqName) } } if len(args) < len(inputSchema.Required) { - return fmt.Errorf("not enough arguments provided - the following flags are required:\n%s", strings.Join(inputSchema.Required, "\n")) + return errors.Newf("not enough arguments provided - the following flags are required:\n%s", strings.Join(inputSchema.Required, "\n")) + } + + return nil +} + +func handleMcpTool(ctx context.Context, client api.Client, tool *mcp.ToolDef, vars map[string]any) error { + jsonRPC := struct { + Version string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params any `json:"params"` + }{ + Version: "2.0", + ID: 1, + Method: "tools/call", + Params: struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + }{ + Name: tool.RawName, + Arguments: vars, + }, + } + + buf := bytes.NewBuffer(nil) + data, err := json.Marshal(jsonRPC) + if err != nil { + return err + } + buf.Write(data) + + req, err := client.NewHTTPRequest(ctx, http.MethodPost, McpPath, buf) + if err != nil { + return err } + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "*/*") - mcp.DerefFlagValues(vars) + resp, err := client.Do(req) + if err != nil { + return err + } - fmt.Println("Flags") - for name, val := range vars { - fmt.Printf("--%s=%v\n", name, val) + jsonData, err := parseSSEResponse(data) + if err != nil { + return err } + fmt.Println(string(jsonData)) return nil } + +func parseSSEResponse(data []byte) ([]byte, error) { + lines := bytes.SplitSeq(data, []byte("\n")) + for line := range lines { + if jsonData, ok := bytes.CutPrefix(line, []byte("data: ")); ok { + return jsonData, nil + } + } + return nil, errors.New("no data found in SSE response") +} diff --git a/internal/mcp/mcp_args.go b/internal/mcp/mcp_args.go index fe2ed00337..09efb2a371 100644 --- a/internal/mcp/mcp_args.go +++ b/internal/mcp/mcp_args.go @@ -32,11 +32,28 @@ func DerefFlagValues(vars map[string]any) { if slice, ok := vv.(strSliceFlag); ok { vv = slice.vals } - vars[k] = vv + if isNil(vv) { + delete(vars, k) + } else { + vars[k] = vv + } } } } +func isNil(v any) bool { + if v == nil { + return true + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Slice, reflect.Map, reflect.Pointer, reflect.Interface: + return rv.IsNil() + default: + return false + } +} + func BuildArgFlagSet(tool *ToolDef) (*flag.FlagSet, map[string]any, error) { if tool == nil { return nil, nil, errors.New("cannot build flagset on nil Tool Definition") From f6e1f59b8c8e77094875dedefc355a64aec8e86b Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Tue, 2 Dec 2025 14:41:55 +0200 Subject: [PATCH 2/7] move tool request to mcp_request.go --- cmd/src/mcp.go | 57 +++---------------------------- internal/mcp/mcp_request.go | 68 +++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 52 deletions(-) create mode 100644 internal/mcp/mcp_request.go diff --git a/cmd/src/mcp.go b/cmd/src/mcp.go index 6fce1c0bdd..d7bb2dd1f6 100644 --- a/cmd/src/mcp.go +++ b/cmd/src/mcp.go @@ -1,13 +1,10 @@ package main import ( - "bytes" "context" "encoding/json" "flag" "fmt" - "io" - "net/http" "strings" "github.com/sourcegraph/src-cli/internal/api" @@ -16,8 +13,6 @@ import ( "github.com/sourcegraph/sourcegraph/lib/errors" ) -const McpPath = ".api/mcp/v1" - func init() { flagSet := flag.NewFlagSet("mcp", flag.ExitOnError) commands = append(commands, &command{ @@ -60,14 +55,14 @@ func mcpMain(args []string) error { return printSchemas(tool) } - flags, vars, err := mcp.BuildToolFlagSet(tool) + flags, vars, err := mcp.BuildArgFlagSet(tool) if err != nil { return err } if err := flags.Parse(flagArgs); err != nil { return err } - sanitizeFlagValues(vars) + mcp.DerefFlagValues(vars) if err := validateToolArgs(tool.InputSchema, args, vars); err != nil { return err @@ -106,58 +101,16 @@ func validateToolArgs(inputSchema mcp.Schema, args []string, vars map[string]any } func handleMcpTool(ctx context.Context, client api.Client, tool *mcp.ToolDef, vars map[string]any) error { - jsonRPC := struct { - Version string `json:"jsonrpc"` - ID int `json:"id"` - Method string `json:"method"` - Params any `json:"params"` - }{ - Version: "2.0", - ID: 1, - Method: "tools/call", - Params: struct { - Name string `json:"name"` - Arguments map[string]any `json:"arguments"` - }{ - Name: tool.RawName, - Arguments: vars, - }, - } - - buf := bytes.NewBuffer(nil) - data, err := json.Marshal(jsonRPC) - if err != nil { - return err - } - buf.Write(data) - - req, err := client.NewHTTPRequest(ctx, http.MethodPost, McpPath, buf) + resp, err := mcp.DoToolRequest(ctx, client, tool, vars) if err != nil { return err } - req.Header.Add("Content-Type", "application/json") - req.Header.Add("Accept", "*/*") - resp, err := client.Do(req) + data, err := mcp.ParseToolResponse(ctx, resp) if err != nil { return err } - jsonData, err := parseSSEResponse(data) - if err != nil { - return err - } - - fmt.Println(string(jsonData)) + fmt.Printf(string(data)) return nil } - -func parseSSEResponse(data []byte) ([]byte, error) { - lines := bytes.SplitSeq(data, []byte("\n")) - for line := range lines { - if jsonData, ok := bytes.CutPrefix(line, []byte("data: ")); ok { - return jsonData, nil - } - } - return nil, errors.New("no data found in SSE response") -} diff --git a/internal/mcp/mcp_request.go b/internal/mcp/mcp_request.go new file mode 100644 index 0000000000..b67949bb97 --- /dev/null +++ b/internal/mcp/mcp_request.go @@ -0,0 +1,68 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + + "github.com/sourcegraph/sourcegraph/lib/errors" + "github.com/sourcegraph/src-cli/internal/api" +) + +const McpURLPath = ".api/mcp/v1" + +func DoToolRequest(ctx context.Context, client api.Client, tool *ToolDef, vars map[string]any) (*http.Response, error) { + jsonRPC := struct { + Version string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params any `json:"params"` + }{ + Version: "2.0", + ID: 1, + Method: "tools/call", + Params: struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + }{ + Name: tool.RawName, + Arguments: vars, + }, + } + + buf := bytes.NewBuffer(nil) + data, err := json.Marshal(jsonRPC) + if err != nil { + return nil, err + } + buf.Write(data) + + req, err := client.NewHTTPRequest(ctx, http.MethodPost, McpURLPath, buf) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "*/*") + + return client.Do(req) +} + +func ParseToolResponse(ctx context.Context, resp *http.Response) ([]byte, error) { + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + // The response is an SSE reponse + // event: + // data: + lines := bytes.SplitSeq(data, []byte("\n")) + for line := range lines { + if jsonData, ok := bytes.CutPrefix(line, []byte("data: ")); ok { + return jsonData, nil + } + } + return nil, errors.New("no data found in SSE response") + +} From 4e0b03e5d0f4bfe535e8d8672402616f10d085e5 Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Tue, 2 Dec 2025 14:47:07 +0200 Subject: [PATCH 3/7] print tool response according to output schema From ef83d36409723384035eeda2d8cbd0409ee0a631 Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Tue, 2 Dec 2025 16:20:51 +0200 Subject: [PATCH 4/7] print structuredContent from JSON RPC response --- cmd/src/mcp.go | 8 ++++++-- internal/mcp/mcp_request.go | 30 ++++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/cmd/src/mcp.go b/cmd/src/mcp.go index d7bb2dd1f6..3d11fb1f8c 100644 --- a/cmd/src/mcp.go +++ b/cmd/src/mcp.go @@ -106,11 +106,15 @@ func handleMcpTool(ctx context.Context, client api.Client, tool *mcp.ToolDef, va return err } - data, err := mcp.ParseToolResponse(ctx, resp) + result, err := mcp.ParseToolResponse(resp) if err != nil { return err } - fmt.Printf(string(data)) + output, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + fmt.Println(string(output)) return nil } diff --git a/internal/mcp/mcp_request.go b/internal/mcp/mcp_request.go index b67949bb97..a4326ef558 100644 --- a/internal/mcp/mcp_request.go +++ b/internal/mcp/mcp_request.go @@ -7,8 +7,9 @@ import ( "io" "net/http" - "github.com/sourcegraph/sourcegraph/lib/errors" "github.com/sourcegraph/src-cli/internal/api" + + "github.com/sourcegraph/sourcegraph/lib/errors" ) const McpURLPath = ".api/mcp/v1" @@ -49,7 +50,32 @@ func DoToolRequest(ctx context.Context, client api.Client, tool *ToolDef, vars m return client.Do(req) } -func ParseToolResponse(ctx context.Context, resp *http.Response) ([]byte, error) { +func ParseToolResponse(resp *http.Response) (map[string]json.RawMessage, error) { + data, err := readSSEResponseData(resp) + if err != nil { + return nil, err + } + + if data == nil { + return map[string]json.RawMessage{}, nil + } + + jsonRPCResp := struct { + Version string `json:"jsonrpc"` + ID int `json:"id"` + Result struct { + Content []json.RawMessage `json:"content"` + StructuredContent map[string]json.RawMessage `json:"structuredContent"` + } `json:"result"` + }{} + if err := json.Unmarshal(data, &jsonRPCResp); err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal MCP JSON-RPC response") + } + + return jsonRPCResp.Result.StructuredContent, nil +} +func readSSEResponseData(resp *http.Response) ([]byte, error) { + defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return nil, err From 84ddb92159afa2c052f555789435ae468781b50f Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Tue, 2 Dec 2025 16:42:09 +0200 Subject: [PATCH 5/7] only register mcp command if SRC_EXPERIMENT_MCP=true --- cmd/src/mcp.go | 14 +++++++++----- internal/mcp/mcp_request.go | 1 - 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/cmd/src/mcp.go b/cmd/src/mcp.go index 3d11fb1f8c..e5da03aa8e 100644 --- a/cmd/src/mcp.go +++ b/cmd/src/mcp.go @@ -5,6 +5,7 @@ import ( "encoding/json" "flag" "fmt" + "os" "strings" "github.com/sourcegraph/src-cli/internal/api" @@ -14,11 +15,13 @@ import ( ) func init() { - flagSet := flag.NewFlagSet("mcp", flag.ExitOnError) - commands = append(commands, &command{ - flagSet: flagSet, - handler: mcpMain, - }) + if os.Getenv("SRC_EXPERIMENT_MCP") == "true" { + flagSet := flag.NewFlagSet("mcp", flag.ExitOnError) + commands = append(commands, &command{ + flagSet: flagSet, + handler: mcpMain, + }) + } } func mcpMain(args []string) error { fmt.Println("NOTE: This command is still experimental") @@ -110,6 +113,7 @@ func handleMcpTool(ctx context.Context, client api.Client, tool *mcp.ToolDef, va if err != nil { return err } + defer resp.Body.Close() output, err := json.MarshalIndent(result, "", " ") if err != nil { diff --git a/internal/mcp/mcp_request.go b/internal/mcp/mcp_request.go index a4326ef558..d0d61f7a5b 100644 --- a/internal/mcp/mcp_request.go +++ b/internal/mcp/mcp_request.go @@ -75,7 +75,6 @@ func ParseToolResponse(resp *http.Response) (map[string]json.RawMessage, error) return jsonRPCResp.Result.StructuredContent, nil } func readSSEResponseData(resp *http.Response) ([]byte, error) { - defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return nil, err From 8353159fc94895e3a4ce2c5f4117bbb085d09d5a Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Fri, 5 Dec 2025 12:47:20 +0200 Subject: [PATCH 6/7] rename ParseToolResponse to DecodeToolResponse --- cmd/src/mcp.go | 2 +- internal/mcp/mcp_request.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/src/mcp.go b/cmd/src/mcp.go index e5da03aa8e..24c588afb7 100644 --- a/cmd/src/mcp.go +++ b/cmd/src/mcp.go @@ -109,7 +109,7 @@ func handleMcpTool(ctx context.Context, client api.Client, tool *mcp.ToolDef, va return err } - result, err := mcp.ParseToolResponse(resp) + result, err := mcp.DecodeToolResponse(resp) if err != nil { return err } diff --git a/internal/mcp/mcp_request.go b/internal/mcp/mcp_request.go index d0d61f7a5b..dbcb0ed97b 100644 --- a/internal/mcp/mcp_request.go +++ b/internal/mcp/mcp_request.go @@ -50,7 +50,7 @@ func DoToolRequest(ctx context.Context, client api.Client, tool *ToolDef, vars m return client.Do(req) } -func ParseToolResponse(resp *http.Response) (map[string]json.RawMessage, error) { +func DecodeToolResponse(resp *http.Response) (map[string]json.RawMessage, error) { data, err := readSSEResponseData(resp) if err != nil { return nil, err From 81a073eeca4676bc6f39f0ea96592a66f41fb4cd Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Mon, 8 Dec 2025 12:21:09 +0200 Subject: [PATCH 7/7] fixup types --- cmd/src/mcp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/src/mcp.go b/cmd/src/mcp.go index 24c588afb7..89ab4b2dce 100644 --- a/cmd/src/mcp.go +++ b/cmd/src/mcp.go @@ -89,7 +89,7 @@ func printSchemas(tool *mcp.ToolDef) error { return nil } -func validateToolArgs(inputSchema mcp.Schema, args []string, vars map[string]any) error { +func validateToolArgs(inputSchema mcp.SchemaObject, args []string, vars map[string]any) error { for _, reqName := range inputSchema.Required { if vars[reqName] == nil { return errors.Newf("no value provided for required flag --%s", reqName)