Skip to content

Commit 718ec6d

Browse files
authored
feat(mcp): add script to dump mcp tool list (#1211)
* add script to dump mcp tool list * parse mcp tool json * fix parsing for when items: true * use lib/errors * temporarily ignore embedded json * move mcp files to internal/mcp * unexport and remove unused structs * rename MCPToolDef to ToolDef and move around structs * rename parser + method to decoder + decode* * simplify types fold: Schema into SchemaObject * review comments * feat(mcp): build flagset from mcp input schema to parse args (#1215) - parse input schema to build flagset for mcp command * merge fixup * fix lint * fix imports
1 parent 2e48170 commit 718ec6d

File tree

9 files changed

+1699
-1
lines changed

9 files changed

+1699
-1
lines changed

cmd/src/config_list.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import (
44
"flag"
55
"fmt"
66

7+
"context"
8+
79
"github.com/sourcegraph/src-cli/internal/api"
8-
"golang.org/x/net/context"
910
)
1011

1112
func init() {

cmd/src/mcp.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"flag"
7+
"fmt"
8+
"strings"
9+
10+
"github.com/sourcegraph/src-cli/internal/api"
11+
"github.com/sourcegraph/src-cli/internal/mcp"
12+
13+
"github.com/sourcegraph/sourcegraph/lib/errors"
14+
)
15+
16+
func init() {
17+
flagSet := flag.NewFlagSet("mcp", flag.ExitOnError)
18+
commands = append(commands, &command{
19+
flagSet: flagSet,
20+
handler: mcpMain,
21+
})
22+
}
23+
func mcpMain(args []string) error {
24+
fmt.Println("NOTE: This command is still experimental")
25+
tools, err := mcp.LoadDefaultToolDefinitions()
26+
if err != nil {
27+
return err
28+
}
29+
30+
subcmd := args[0]
31+
if subcmd == "list-tools" {
32+
fmt.Println("The following tools are available:")
33+
for name := range tools {
34+
fmt.Printf(" • %s\n", name)
35+
}
36+
fmt.Println("\nUSAGE:")
37+
fmt.Printf(" • Invoke a tool\n")
38+
fmt.Printf(" src mcp <tool-name> <flags>\n")
39+
fmt.Printf("\n • View the Input / Output Schema of a tool\n")
40+
fmt.Printf(" src mcp <tool-name> schema\n")
41+
fmt.Printf("\n • List the available flags of a tool\n")
42+
fmt.Printf(" src mcp <tool-name> -h\n")
43+
fmt.Printf("\n • View the Input / Output Schema of a tool\n")
44+
fmt.Printf(" src mcp <tool-name> schema\n")
45+
return nil
46+
}
47+
48+
tool, ok := tools[subcmd]
49+
if !ok {
50+
return fmt.Errorf("tool definition for %q not found - run src mcp list-tools to see a list of available tools", subcmd)
51+
}
52+
53+
flagArgs := args[1:] // skip subcommand name
54+
if len(args) > 1 && args[1] == "schema" {
55+
return printSchemas(tool)
56+
}
57+
58+
flags, vars, err := mcp.BuildArgFlagSet(tool)
59+
if err != nil {
60+
return err
61+
}
62+
if err := flags.Parse(flagArgs); err != nil {
63+
return err
64+
}
65+
mcp.DerefFlagValues(vars)
66+
67+
if err := validateToolArgs(tool.InputSchema, args, vars); err != nil {
68+
return err
69+
}
70+
71+
apiClient := cfg.apiClient(nil, flags.Output())
72+
return handleMcpTool(context.Background(), apiClient, tool, vars)
73+
}
74+
75+
func printSchemas(tool *mcp.ToolDef) error {
76+
input, err := json.MarshalIndent(tool.InputSchema, "", " ")
77+
if err != nil {
78+
return err
79+
}
80+
output, err := json.MarshalIndent(tool.OutputSchema, "", " ")
81+
if err != nil {
82+
return err
83+
}
84+
85+
fmt.Printf("Input:\n%v\nOutput:\n%v\n", string(input), string(output))
86+
return nil
87+
}
88+
89+
func validateToolArgs(inputSchema mcp.SchemaObject, args []string, vars map[string]any) error {
90+
for _, reqName := range inputSchema.Required {
91+
if vars[reqName] == nil {
92+
return errors.Newf("no value provided for required flag --%s", reqName)
93+
}
94+
}
95+
96+
if len(args) < len(inputSchema.Required) {
97+
return errors.Newf("not enough arguments provided - the following flags are required:\n%s", strings.Join(inputSchema.Required, "\n"))
98+
}
99+
100+
return nil
101+
}
102+
103+
func handleMcpTool(ctx context.Context, client api.Client, tool *mcp.ToolDef, vars map[string]any) error {
104+
resp, err := mcp.DoToolRequest(ctx, client, tool, vars)
105+
if err != nil {
106+
return err
107+
}
108+
109+
result, err := mcp.DecodeToolResponse(resp)
110+
if err != nil {
111+
return err
112+
}
113+
defer resp.Body.Close()
114+
115+
output, err := json.MarshalIndent(result, "", " ")
116+
if err != nil {
117+
return err
118+
}
119+
fmt.Println(string(output))
120+
return nil
121+
}

internal/mcp/mcp_args.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+
"flag"
5+
"fmt"
6+
"reflect"
7+
"strings"
8+
9+
"github.com/sourcegraph/sourcegraph/lib/errors"
10+
)
11+
12+
var _ flag.Value = (*strSliceFlag)(nil)
13+
14+
type strSliceFlag struct {
15+
vals []string
16+
}
17+
18+
func (s *strSliceFlag) Set(v string) error {
19+
s.vals = append(s.vals, v)
20+
return nil
21+
}
22+
23+
func (s *strSliceFlag) String() string {
24+
return strings.Join(s.vals, ",")
25+
}
26+
27+
func DerefFlagValues(vars map[string]any) {
28+
for k, v := range vars {
29+
rfl := reflect.ValueOf(v)
30+
if rfl.Kind() == reflect.Pointer {
31+
vv := rfl.Elem().Interface()
32+
if slice, ok := vv.(strSliceFlag); ok {
33+
vv = slice.vals
34+
}
35+
if isNil(vv) {
36+
delete(vars, k)
37+
} else {
38+
vars[k] = vv
39+
}
40+
}
41+
}
42+
}
43+
44+
func isNil(v any) bool {
45+
if v == nil {
46+
return true
47+
}
48+
rv := reflect.ValueOf(v)
49+
switch rv.Kind() {
50+
case reflect.Slice, reflect.Map, reflect.Pointer, reflect.Interface:
51+
return rv.IsNil()
52+
default:
53+
return false
54+
}
55+
}
56+
57+
func BuildArgFlagSet(tool *ToolDef) (*flag.FlagSet, map[string]any, error) {
58+
if tool == nil {
59+
return nil, nil, errors.New("cannot build flagset on nil Tool Definition")
60+
}
61+
fs := flag.NewFlagSet(tool.Name, flag.ContinueOnError)
62+
flagVars := map[string]any{}
63+
64+
for name, pVal := range tool.InputSchema.Properties {
65+
switch pv := pVal.(type) {
66+
case *SchemaPrimitive:
67+
switch pv.Type {
68+
case "integer":
69+
dst := fs.Int(name, 0, pv.Description)
70+
flagVars[name] = dst
71+
72+
case "boolean":
73+
dst := fs.Bool(name, false, pv.Description)
74+
flagVars[name] = dst
75+
case "string":
76+
dst := fs.String(name, "", pv.Description)
77+
flagVars[name] = dst
78+
default:
79+
return nil, nil, fmt.Errorf("unknown schema primitive kind %q", pv.Type)
80+
81+
}
82+
case *SchemaArray:
83+
strSlice := new(strSliceFlag)
84+
fs.Var(strSlice, name, pv.Description)
85+
flagVars[name] = strSlice
86+
case *SchemaObject:
87+
// TODO(burmudar): we can support SchemaObject as part of stdin echo '{ stuff }' | sg mcp commit-search
88+
// not supported yet
89+
// Also support sg mcp commit-search --json '{ stuff }'
90+
}
91+
}
92+
93+
return fs, flagVars, nil
94+
}

internal/mcp/mcp_args_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package mcp
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestFlagSetParse(t *testing.T) {
8+
toolJSON := []byte(`{
9+
"tools": [
10+
{
11+
"name": "sg_test_tool",
12+
"description": "test description",
13+
"inputSchema": {
14+
"type": "object",
15+
"$schema": "https://localhost/schema-draft/2025-07",
16+
"required": ["values"],
17+
"properties": {
18+
"repos": {
19+
"type": "array",
20+
"items": {
21+
"type": "string"
22+
}
23+
},
24+
"tag": {
25+
"type": "string",
26+
"items": true
27+
},
28+
"count": {
29+
"type": "integer"
30+
},
31+
"boolFlag": {
32+
"type": "boolean"
33+
}
34+
}
35+
},
36+
"outputSchema": {
37+
"type": "object",
38+
"$schema": "https://localhost/schema-draft/2025-07",
39+
"properties": {
40+
"result": { "type": "string" }
41+
}
42+
}
43+
}
44+
]
45+
}`)
46+
47+
defs, err := loadToolDefinitions(toolJSON)
48+
if err != nil {
49+
t.Fatalf("failed to load tool json: %v", err)
50+
}
51+
52+
flagSet, vars, err := BuildArgFlagSet(defs["test-tool"])
53+
if err != nil {
54+
t.Fatalf("failed to build flagset from mcp tool definition: %v", err)
55+
}
56+
57+
if len(vars) == 0 {
58+
t.Fatalf("vars from buildArgFlagSet should not be empty")
59+
}
60+
61+
args := []string{"-repos=A", "-repos=B", "-count=10", "-boolFlag", "-tag=testTag"}
62+
63+
if err := flagSet.Parse(args); err != nil {
64+
t.Fatalf("flagset parsing failed: %v", err)
65+
}
66+
DerefFlagValues(vars)
67+
68+
if v, ok := vars["repos"].([]string); ok {
69+
if len(v) != 2 {
70+
t.Fatalf("expected flag 'repos' values to have length %d but got %d", 2, len(v))
71+
}
72+
} else {
73+
t.Fatalf("expected flag 'repos' to have type of []string but got %T", v)
74+
}
75+
if v, ok := vars["tag"].(string); ok {
76+
if v != "testTag" {
77+
t.Fatalf("expected flag 'tag' values to have value %q but got %q", "testTag", v)
78+
}
79+
} else {
80+
t.Fatalf("expected flag 'tag' to have type of string but got %T", v)
81+
}
82+
if v, ok := vars["count"].(int); ok {
83+
if v != 10 {
84+
t.Fatalf("expected flag 'count' values to have value %d but got %d", 10, v)
85+
}
86+
} else {
87+
t.Fatalf("expected flag 'count' to have type of int but got %T", v)
88+
}
89+
if v, ok := vars["boolFlag"].(bool); ok {
90+
if v != true {
91+
t.Fatalf("expected flag 'boolFlag' values to have value %v but got %v", true, v)
92+
}
93+
} else {
94+
t.Fatalf("expected flag 'boolFlag' to have type of bool but got %T", v)
95+
}
96+
97+
}

0 commit comments

Comments
 (0)