Skip to content

Add Model Context Protocol (MCP) support to Serpent #25

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ type Command struct {
Options OptionSet
Annotations Annotations

// Tool is the name of the MCP tool this command provides.
// If set, the command can be invoked via MCP as a tool.
// Tool and Resource are mutually exclusive.
Tool string

// ToolFlags is a set of flags to automatically set for a given MCP command.
ToolFlags []string

// Resource is the URI of the MCP resource this command provides.
// If set, the command can be accessed via MCP as a resource.
// Tool and Resource are mutually exclusive.
Resource string

// Middleware is called before the Handler.
// Use Chain() to combine multiple middlewares.
Middleware MiddlewareFunc
Expand Down Expand Up @@ -106,6 +119,11 @@ func (c *Command) init() error {
}
var merr error

// Validate that Tool and Resource are mutually exclusive
if c.Tool != "" && c.Resource != "" {
merr = errors.Join(merr, xerrors.Errorf("command %q cannot have both Tool and Resource set", c.Name()))
}

for i := range c.Options {
opt := &c.Options[i]
if opt.Name == "" {
Expand Down Expand Up @@ -558,6 +576,12 @@ func findArg(want string, args []string, fs *pflag.FlagSet) (int, error) {
return -1, xerrors.Errorf("arg %s not found", want)
}

// IsMCPEnabled returns true if the command is accessible via MCP
// (has either Tool or Resource field set)
func (c *Command) IsMCPEnabled() bool {
return c.Tool != "" || c.Resource != ""
}

// Run executes the command.
// If two command share a flag name, the first command wins.
//
Expand Down
167 changes: 167 additions & 0 deletions example/mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Serpent MCP Server Example

This example demonstrates how to use the Model Context Protocol (MCP) functionality in Serpent to create a command-line tool that can also be used as an MCP server.

## What is MCP?

The Model Context Protocol (MCP) is a protocol for communication between AI models and external tools or resources. It allows AI models to invoke tools and access resources provided by MCP servers.

## How to Use

### Running as a CLI Tool

You can run the example as a normal CLI tool:

```bash
# Echo a message
go run main.go echo "Hello, World!"

# Get version information
go run main.go version

# Show help
go run main.go --help
```

### Running as an MCP Server

You can run the example as an MCP server using the `mcp` subcommand:

```bash
go run main.go mcp
```

This will start an MCP server that listens on stdin/stdout for JSON-RPC 2.0 requests.

## MCP Protocol

### Lifecycle

The MCP server follows the standard MCP lifecycle:

1. The client sends an `initialize` request to the server
2. The server responds with its capabilities
3. The client sends an `initialized` notification
4. After this, normal message exchange can begin

All MCP methods will return an error if called before the initialization process is complete.

### Methods

The MCP server implements the following JSON-RPC 2.0 methods:

- `initialize`: Initializes the MCP server and returns its capabilities
- `notifications/initialized`: Notifies the server that initialization is complete
- `ping`: Simple ping method to check server availability
- `tools/list`: Lists all available tools
- `tools/call`: Invokes a tool with the given arguments
- `resources/list`: Lists all available resources
- `resources/templates/list`: Lists all available resource templates
- `resources/read`: Accesses a resource with the given URI

### Example Requests

Here are some example JSON-RPC 2.0 requests you can send to the MCP server:

#### Initialize

```json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"manual-test-client","version":"1.0.0"},"capabilities":{}}}
```

Response:
```json
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":true,"resources":true}}}
```

#### Initialized

```json
{"jsonrpc":"2.0","id":2,"method":"notifications/initialized"}
```

#### List Tools

```json
{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}
```

#### List Resources

```json
{"jsonrpc":"2.0","id":4,"method":"resources/list","params":{}}
```

#### Invoke Tool

```json
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"echo","arguments":{"_":"Hello from MCP!"}}}
```

#### Access Resource

```json
{"jsonrpc":"2.0","id":6,"method":"resources/read","params":{"uri":"version"}}
```

### Complete Initialization Example

Here's a complete example of the initialization process:

```json
// Client sends initialize request
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"manual-test-client","version":"1.0.0"},"capabilities":{}}}

// Server responds with capabilities
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":true,"resources":true}}}

// Client sends initialized notification
{"jsonrpc":"2.0","id":2,"method":"notifications/initialized"}

// Server acknowledges (optional, since initialized is technically a notification)
{"jsonrpc":"2.0","id":2,"result":{}}

// Now client can use MCP methods
{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}
```

## How to Implement MCP in Your Own Commands

To implement MCP in your own Serpent commands:

1. Add the `Tool` field to commands that should be invokable as MCP tools
2. Add the `Resource` field to commands that should be accessible as MCP resources
3. Add the MCP command to your root command using `root.AddMCPCommand()`

Example:

```go
// Create a command that will be exposed as an MCP tool
echoCmd := &serpent.Command{
Use: "echo [message]",
Short: "Echo a message",
Tool: "echo", // This makes the command available as an MCP tool
Handler: func(inv *serpent.Invocation) error {
// Command implementation
},
}

// Create a command that will be exposed as an MCP resource
versionCmd := &serpent.Command{
Use: "version",
Short: "Get version information",
Resource: "version", // This makes the command available as an MCP resource
Handler: func(inv *serpent.Invocation) error {
// Command implementation
},
}

// Add the MCP command to the root command
root.AddSubcommands(serpent.MCPCommand())
```

## Notes

- A command can have either a `Tool` field or a `Resource` field, but not both
- Commands with neither `Tool` nor `Resource` set will not be accessible via MCP
- The MCP server communicates using JSON-RPC 2.0 over stdin/stdout
87 changes: 87 additions & 0 deletions example/mcp/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package main

import (
"encoding/json"
"fmt"
"os"
"strings"

"github.com/coder/serpent"
)

func main() {
// Create a root command
root := &serpent.Command{
Use: "mcp-example",
Short: "Example MCP server",
Long: "An example of how to use the MCP functionality in serpent.",
}

var repeats int64 = 2

// Add a command that will be exposed as an MCP tool
echoCmd := &serpent.Command{
Use: "echo [message]",
Short: "Echo a message",
Tool: "echo", // This makes the command available as an MCP tool
Options: []serpent.Option{
{
Name: "repeat",
Flag: "repeat", // Add the Flag field so it's exposed in JSON Schema
Description: "Number of times to repeat the message.",
Default: "2",
Value: serpent.Int64Of(&repeats),
},
},
Handler: func(inv *serpent.Invocation) error {
message := "Hello, World!"
if len(inv.Args) > 0 {
message = strings.Join(inv.Args, " ")
}
for i := int64(0); i < repeats; i++ {
if _, err := fmt.Fprintln(inv.Stdout, message); err != nil {
return err
}
}
return nil
},
}
root.AddSubcommands(echoCmd)

// Add a command that will be exposed as an MCP resource
versionCmd := &serpent.Command{
Use: "version",
Short: "Get version information",
Resource: "version", // This makes the command available as an MCP resource
Handler: func(inv *serpent.Invocation) error {
version := map[string]string{
"version": "1.0.0",
"name": "serpent-mcp-example",
"author": "Coder",
}
encoder := json.NewEncoder(inv.Stdout)
return encoder.Encode(version)
},
}
root.AddSubcommands(versionCmd)

// Add a command that will not be exposed via MCP
hiddenCmd := &serpent.Command{
Use: "hidden",
Short: "This command is not exposed via MCP",
Handler: func(inv *serpent.Invocation) error {
_, err := fmt.Fprintln(inv.Stdout, "This command is not exposed via MCP")
return err
},
}
root.AddSubcommands(hiddenCmd)

// Add the MCP command to the root command
root.AddSubcommands(serpent.MCPCommand())

// Run the command
if err := root.Invoke(os.Args[1:]...).WithOS().Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
5 changes: 2 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ require (
github.com/pion/udp v0.1.4
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.8.4
golang.org/x/crypto v0.19.0
golang.org/x/exp v0.0.0-20240213143201-ec583247a57a
golang.org/x/term v0.17.0
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028
gopkg.in/yaml.v3 v3.0.1
)
Expand All @@ -24,7 +24,7 @@ require (
github.com/charmbracelet/lipgloss v0.8.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
Expand All @@ -39,7 +39,6 @@ require (
go.opentelemetry.io/otel/trace v1.19.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sys v0.17.0 // indirect
golang.org/x/term v0.17.0 // indirect
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
Expand Down Expand Up @@ -92,8 +94,6 @@ go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1
go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE=
golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
Expand Down
10 changes: 4 additions & 6 deletions help.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (

"github.com/mitchellh/go-wordwrap"
"github.com/muesli/termenv"
"golang.org/x/crypto/ssh/terminal"
"golang.org/x/term"
"golang.org/x/xerrors"

"github.com/coder/pretty"
Expand All @@ -31,7 +31,7 @@ type optionGroup struct {
}

func ttyWidth() int {
width, _, err := terminal.GetSize(0)
width, _, err := term.GetSize(0)
if err != nil {
return 80
}
Expand Down Expand Up @@ -73,10 +73,8 @@ func prettyHeader(s string) string {
}

var defaultHelpTemplate = func() *template.Template {
var (
optionFg = pretty.FgColor(
helpColor("#04A777"),
)
optionFg := pretty.FgColor(
helpColor("#04A777"),
)
return template.Must(
template.New("usage").Funcs(
Expand Down
8 changes: 8 additions & 0 deletions help.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@
{{" Aliases: "}} {{- joinStrings .}}
{{- end }}

{{ with .Tool }}
{{" MCP Tool: "}} {{- . }}
{{- end }}

{{ with .Resource }}
{{" MCP Resource: "}} {{- . }}
{{- end }}

{{- with .Long}}
{{"\n"}}
{{- indent . 2}}
Expand Down
Loading