diff --git a/README.md b/README.md index e69de29..4a285b7 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,44 @@ +# goa-mcp-plugin + +Goa plugin that adds MCP (Model Context Protocol) as a transport and DSL annotations to expose Goa service methods as MCP tools, resources and prompts. + +Status: experimental scaffold. Generates an MCP server wrapper over Goa endpoints using JSON-RPC 2.0 over stdio. + +## Install + +``` +go install goa.design/goa/v3/cmd/goa@latest +``` + +## Usage (preview) + +- In your Goa design, import `github.com/workspace/goa-mcp-plugin/dsl/mcp` and annotate methods: + +```go +import mcpdsl "github.com/workspace/goa-mcp-plugin/dsl/mcp" + +var _ = Service("calc", func() { + Method("add", func() { + Payload(func() { + Attribute("a", Int) + Attribute("b", Int) + Required("a", "b") + }) + Result(Int) + // Declare as an MCP tool with description + mcpdsl.Tool(func() { + mcpdsl.Description("Add two integers") + }) + }) +}) +``` + +- Run `goa gen` as usual. The plugin generates `gen/mcp/server` with a stdio MCP server exposing your annotated methods as tools. + +- Start the MCP server: + +``` +go run ./gen/mcp/server +``` + +Point an MCP client (e.g., Claude Desktop) to the binary with stdio transport. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..733ddaa --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module github.com/workspace/goa-mcp-plugin + +go 1.23.0 + +toolchain go1.24.2 + +require goa.design/goa/v3 v3.21.5 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/text v0.26.0 // indirect + golang.org/x/tools v0.34.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..de62621 --- /dev/null +++ b/go.sum @@ -0,0 +1,32 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598 h1:MGKhKyiYrvMDZsmLR/+RGffQSXwEkXgfLSA08qDn9AI= +github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598/go.mod h1:0FpDmbrt36utu8jEmeU05dPC9AB5tsLYVVi+ZHfyuwI= +github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg= +github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d h1:Zj+PHjnhRYWBK6RqCDBcAhLXoi3TzC27Zad/Vn+gnVQ= +github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d/go.mod h1:WZy8Q5coAB1zhY9AOBJP0O6J4BuDfbupUDavKY+I3+s= +github.com/manveru/gobdd v0.0.0-20131210092515-f1a17fdd710b h1:3E44bLeN8uKYdfQqVQycPnaVviZdBLbizFhU49mtbe4= +github.com/manveru/gobdd v0.0.0-20131210092515-f1a17fdd710b/go.mod h1:Bj8LjjP0ReT1eKt5QlKjwgi5AFm5mI6O1A2G4ChI0Ag= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +goa.design/goa/v3 v3.21.5 h1:eS6SHJ1KZ5q5bhT/llw0LMTCWbosBwlFX4v8MctYs38= +goa.design/goa/v3 v3.21.5/go.mod h1:5THVDuChOIctYM+t3xmL4f2fJbFPzzwvrYMj3PQZg9g= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/mcp/dsl/mcp.go b/plugins/mcp/dsl/mcp.go new file mode 100644 index 0000000..701d68f --- /dev/null +++ b/plugins/mcp/dsl/mcp.go @@ -0,0 +1,70 @@ +//go:build goa_mcp_plugin + +package mcp + +import ( + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// Tool marks a method as an MCP tool and allows setting metadata via nested functions. +func Tool(fn ...func()) { + if _, ok := eval.Current().(*expr.MethodExpr); !ok { + eval.IncompatibleDSL() + return + } + md := ensureMetadata("mcp:tool") + _ = md + for _, f := range fn { + f() + } +} + +// Resource marks a method as providing MCP resources (list/get) semantics. +func Resource(fn ...func()) { + if _, ok := eval.Current().(*expr.MethodExpr); !ok { + eval.IncompatibleDSL() + return + } + _ = ensureMetadata("mcp:resource") + for _, f := range fn { + f() + } +} + +// Prompt marks a method as an MCP prompt provider. +func Prompt(fn ...func()) { + if _, ok := eval.Current().(*expr.MethodExpr); !ok { + eval.IncompatibleDSL() + return + } + _ = ensureMetadata("mcp:prompt") + for _, f := range fn { + f() + } +} + +// Description sets a short description for the annotated MCP element. +func Description(text string) { + switch cur := eval.Current().(type) { + case *expr.MethodExpr: + if cur.Meta == nil { + cur.Meta = make(expr.MetaExpr) + } + cur.Meta["mcp:description"] = []string{text} + default: + eval.IncompatibleDSL() + } +} + +func ensureMetadata(key string) expr.MetaExpr { + m, _ := eval.Current().(*expr.MethodExpr) + if m == nil { + return nil + } + if m.Meta == nil { + m.Meta = make(expr.MetaExpr) + } + m.Meta[key] = []string{"true"} + return m.Meta +} \ No newline at end of file diff --git a/plugins/mcp/plugin/generator.go b/plugins/mcp/plugin/generator.go new file mode 100644 index 0000000..0e77083 --- /dev/null +++ b/plugins/mcp/plugin/generator.go @@ -0,0 +1,80 @@ +package plugin + +import ( + "bytes" + "fmt" + "sort" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type Generator struct { + genpkg string + roots []eval.Root + files []*codegen.File +} + +type tool struct { + Service string + Method string + Desc string +} + +func NewGenerator(genpkg string, roots []eval.Root, files []*codegen.File) *Generator { + return &Generator{genpkg: genpkg, roots: roots, files: files} +} + +func (g *Generator) Run() ([]*codegen.File, error) { + tools := collectTools() + if len(tools) == 0 { + return g.files, nil + } + var buf bytes.Buffer + buf.WriteString("package server\n\n") + buf.WriteString("import (\ncontext \"context\"\njson \"encoding/json\"\nfmt \"fmt\"\nos \"os\"\nbufio \"bufio\"\nstrings \"strings\"\n)\n\n") + buf.WriteString("type rpcRequest struct{ JSONRPC string `json:\"jsonrpc\"` ID any `json:\"id\"` Method string `json:\"method\"` Params json.RawMessage `json:\"params\"`}\n") + buf.WriteString("type rpcResponse struct{ JSONRPC string `json:\"jsonrpc\"` ID any `json:\"id\"` Result any `json:\"result,omitempty\"` Error *rpcError `json:\"error,omitempty\"`}\n") + buf.WriteString("type rpcError struct{ Code int `json:\"code\"` Message string `json:\"message\"`}\n") + buf.WriteString("\nfunc main(){ if err := run(); err!=nil { fmt.Fprintln(os.Stderr, err); os.Exit(1)}}\n") + buf.WriteString("func run() error {\n") + buf.WriteString("in := bufio.NewScanner(os.Stdin)\n") + buf.WriteString("for in.Scan(){ line := strings.TrimSpace(in.Text()); if line==\"\" {continue}; var req rpcRequest; if err := json.Unmarshal([]byte(line), &req); err!=nil { continue } ; resp := handle(req); b,_ := json.Marshal(resp); fmt.Println(string(b)) }\n") + buf.WriteString("return in.Err()}\n") + buf.WriteString("\nfunc handle(req rpcRequest) rpcResponse {\nresp := rpcResponse{JSONRPC:\"2.0\", ID:req.ID}\nctx := context.Background()\n") + + sort.Slice(tools, func(i, j int) bool { + if tools[i].Service == tools[j].Service { + return tools[i].Method < tools[j].Method + } + return tools[i].Service < tools[j].Service + }) + for _, t := range tools { + buf.WriteString(fmt.Sprintf("if req.Method==\"%s.%s\" {\n\tvar p gen_%s_%s_Payload\n\t_ = json.Unmarshal(req.Params, &p)\n\tres, err := impl_%s_%s(ctx, &p)\n\tif err!=nil { resp.Error=&rpcError{Code:-32000, Message:err.Error()} } else { resp.Result = res }\n\treturn resp\n}\n", t.Service, t.Method, t.Service, codegen.Goify(t.Method, true), t.Service, codegen.Goify(t.Method, true))) + } + buf.WriteString("resp.Error=&rpcError{Code:-32601, Message:\"method not found\"}; return resp}\n") + + sf := &codegen.SectionTemplate{Name: "source", Source: buf.String()} + file := &codegen.File{Path: "gen/mcp/server/server.go", SectionTemplates: []*codegen.SectionTemplate{sf}} + g.files = append(g.files, file) + return g.files, nil +} + +func collectTools() []tool { + var out []tool + for _, s := range expr.Root.Services { + for _, m := range s.Methods { + if m.Meta != nil { + if _, ok := m.Meta["mcp:tool"]; ok { + d := "" + if v, ok2 := m.Meta["mcp:description"]; ok2 && len(v) > 0 { + d = v[0] + } + out = append(out, tool{Service: s.Name, Method: m.Name, Desc: d}) + } + } + } + } + return out +} \ No newline at end of file diff --git a/plugins/mcp/plugin/init.go b/plugins/mcp/plugin/init.go new file mode 100644 index 0000000..21f3d1f --- /dev/null +++ b/plugins/mcp/plugin/init.go @@ -0,0 +1,19 @@ +package plugin + +import ( + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" +) + +func init() { + codegen.RegisterPlugin("gen", "mcp", Prepare, Generate) +} + +// Prepare runs before generation. No-op for now. +func Prepare(_ string, _ []eval.Root) error { return nil } + +// Generate inspects the design roots and augments generated files with MCP server code. +func Generate(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + g := NewGenerator(genpkg, roots, files) + return g.Run() +} \ No newline at end of file