Skip to content

Commit ac46dea

Browse files
committed
Add Model Context Protocol (MCP) support to Serpent
- Implement MCP server functionality in a new example application. - Create README.md to document MCP usage, lifecycle, and methods. - Develop main.go to define commands and expose them as MCP tools and resources. - Introduce mcp.go to handle MCP server logic, including JSON-RPC request processing. - Add tests for MCP server setup, command validation, and JSON schema generation. - Update help template to display MCP tool and resource information. Change-Id: I162ad9d8f1c8d4f5b5bdc37fefb1c99dce179bb2 Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent 5f3d052 commit ac46dea

File tree

6 files changed

+1543
-0
lines changed

6 files changed

+1543
-0
lines changed

command.go

+21
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ type Command struct {
5959
Options OptionSet
6060
Annotations Annotations
6161

62+
// Tool is the name of the MCP tool this command provides.
63+
// If set, the command can be invoked via MCP as a tool.
64+
// Tool and Resource are mutually exclusive.
65+
Tool string
66+
67+
// Resource is the URI of the MCP resource this command provides.
68+
// If set, the command can be accessed via MCP as a resource.
69+
// Tool and Resource are mutually exclusive.
70+
Resource string
71+
6272
// Middleware is called before the Handler.
6373
// Use Chain() to combine multiple middlewares.
6474
Middleware MiddlewareFunc
@@ -106,6 +116,11 @@ func (c *Command) init() error {
106116
}
107117
var merr error
108118

119+
// Validate that Tool and Resource are mutually exclusive
120+
if c.Tool != "" && c.Resource != "" {
121+
merr = errors.Join(merr, xerrors.Errorf("command %q cannot have both Tool and Resource set", c.Name()))
122+
}
123+
109124
for i := range c.Options {
110125
opt := &c.Options[i]
111126
if opt.Name == "" {
@@ -558,6 +573,12 @@ func findArg(want string, args []string, fs *pflag.FlagSet) (int, error) {
558573
return -1, xerrors.Errorf("arg %s not found", want)
559574
}
560575

576+
// IsMCPEnabled returns true if the command is accessible via MCP
577+
// (has either Tool or Resource field set)
578+
func (c *Command) IsMCPEnabled() bool {
579+
return c.Tool != "" || c.Resource != ""
580+
}
581+
561582
// Run executes the command.
562583
// If two command share a flag name, the first command wins.
563584
//

example/mcp/README.md

+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# Serpent MCP Server Example
2+
3+
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.
4+
5+
## What is MCP?
6+
7+
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.
8+
9+
## How to Use
10+
11+
### Running as a CLI Tool
12+
13+
You can run the example as a normal CLI tool:
14+
15+
```bash
16+
# Echo a message
17+
go run main.go echo "Hello, World!"
18+
19+
# Get version information
20+
go run main.go version
21+
22+
# Show help
23+
go run main.go --help
24+
```
25+
26+
### Running as an MCP Server
27+
28+
You can run the example as an MCP server using the `mcp` subcommand:
29+
30+
```bash
31+
go run main.go mcp
32+
```
33+
34+
This will start an MCP server that listens on stdin/stdout for JSON-RPC 2.0 requests.
35+
36+
## MCP Protocol
37+
38+
### Lifecycle
39+
40+
The MCP server follows the standard MCP lifecycle:
41+
42+
1. The client sends an `initialize` request to the server
43+
2. The server responds with its capabilities
44+
3. The client sends an `initialized` notification
45+
4. After this, normal message exchange can begin
46+
47+
All MCP methods will return an error if called before the initialization process is complete.
48+
49+
### Methods
50+
51+
The MCP server implements the following JSON-RPC 2.0 methods:
52+
53+
- `initialize`: Initializes the MCP server and returns its capabilities
54+
- `notifications/initialized`: Notifies the server that initialization is complete
55+
- `ping`: Simple ping method to check server availability
56+
- `tools/list`: Lists all available tools
57+
- `tools/call`: Invokes a tool with the given arguments
58+
- `resources/list`: Lists all available resources
59+
- `resources/templates/list`: Lists all available resource templates
60+
- `resources/read`: Accesses a resource with the given URI
61+
62+
### Example Requests
63+
64+
Here are some example JSON-RPC 2.0 requests you can send to the MCP server:
65+
66+
#### Initialize
67+
68+
```json
69+
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"manual-test-client","version":"1.0.0"},"capabilities":{}}}
70+
```
71+
72+
Response:
73+
```json
74+
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":true,"resources":true}}}
75+
```
76+
77+
#### Initialized
78+
79+
```json
80+
{"jsonrpc":"2.0","id":2,"method":"notifications/initialized"}
81+
```
82+
83+
#### List Tools
84+
85+
```json
86+
{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}
87+
```
88+
89+
#### List Resources
90+
91+
```json
92+
{"jsonrpc":"2.0","id":4,"method":"resources/list","params":{}}
93+
```
94+
95+
#### Invoke Tool
96+
97+
```json
98+
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"echo","arguments":{"_":"Hello from MCP!"}}}
99+
```
100+
101+
#### Access Resource
102+
103+
```json
104+
{"jsonrpc":"2.0","id":6,"method":"resources/read","params":{"uri":"version"}}
105+
```
106+
107+
### Complete Initialization Example
108+
109+
Here's a complete example of the initialization process:
110+
111+
```json
112+
// Client sends initialize request
113+
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"manual-test-client","version":"1.0.0"},"capabilities":{}}}
114+
115+
// Server responds with capabilities
116+
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":true,"resources":true}}}
117+
118+
// Client sends initialized notification
119+
{"jsonrpc":"2.0","id":2,"method":"notifications/initialized"}
120+
121+
// Server acknowledges (optional, since initialized is technically a notification)
122+
{"jsonrpc":"2.0","id":2,"result":{}}
123+
124+
// Now client can use MCP methods
125+
{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}
126+
```
127+
128+
## How to Implement MCP in Your Own Commands
129+
130+
To implement MCP in your own Serpent commands:
131+
132+
1. Add the `Tool` field to commands that should be invokable as MCP tools
133+
2. Add the `Resource` field to commands that should be accessible as MCP resources
134+
3. Add the MCP command to your root command using `root.AddMCPCommand()`
135+
136+
Example:
137+
138+
```go
139+
// Create a command that will be exposed as an MCP tool
140+
echoCmd := &serpent.Command{
141+
Use: "echo [message]",
142+
Short: "Echo a message",
143+
Tool: "echo", // This makes the command available as an MCP tool
144+
Handler: func(inv *serpent.Invocation) error {
145+
// Command implementation
146+
},
147+
}
148+
149+
// Create a command that will be exposed as an MCP resource
150+
versionCmd := &serpent.Command{
151+
Use: "version",
152+
Short: "Get version information",
153+
Resource: "version", // This makes the command available as an MCP resource
154+
Handler: func(inv *serpent.Invocation) error {
155+
// Command implementation
156+
},
157+
}
158+
159+
// Add the MCP command to the root command
160+
root.AddSubcommands(serpent.MCPCommand())
161+
```
162+
163+
## Notes
164+
165+
- A command can have either a `Tool` field or a `Resource` field, but not both
166+
- Commands with neither `Tool` nor `Resource` set will not be accessible via MCP
167+
- The MCP server communicates using JSON-RPC 2.0 over stdin/stdout

example/mcp/main.go

+87
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/coder/serpent"
10+
)
11+
12+
func main() {
13+
// Create a root command
14+
root := &serpent.Command{
15+
Use: "mcp-example",
16+
Short: "Example MCP server",
17+
Long: "An example of how to use the MCP functionality in serpent.",
18+
}
19+
20+
var repeats int64 = 2
21+
22+
// Add a command that will be exposed as an MCP tool
23+
echoCmd := &serpent.Command{
24+
Use: "echo [message]",
25+
Short: "Echo a message",
26+
Tool: "echo", // This makes the command available as an MCP tool
27+
Options: []serpent.Option{
28+
{
29+
Name: "repeat",
30+
Flag: "repeat", // Add the Flag field so it's exposed in JSON Schema
31+
Description: "Number of times to repeat the message.",
32+
Default: "2",
33+
Value: serpent.Int64Of(&repeats),
34+
},
35+
},
36+
Handler: func(inv *serpent.Invocation) error {
37+
message := "Hello, World!"
38+
if len(inv.Args) > 0 {
39+
message = strings.Join(inv.Args, " ")
40+
}
41+
for i := int64(0); i < repeats; i++ {
42+
if _, err := fmt.Fprintln(inv.Stdout, message); err != nil {
43+
return err
44+
}
45+
}
46+
return nil
47+
},
48+
}
49+
root.AddSubcommands(echoCmd)
50+
51+
// Add a command that will be exposed as an MCP resource
52+
versionCmd := &serpent.Command{
53+
Use: "version",
54+
Short: "Get version information",
55+
Resource: "version", // This makes the command available as an MCP resource
56+
Handler: func(inv *serpent.Invocation) error {
57+
version := map[string]string{
58+
"version": "1.0.0",
59+
"name": "serpent-mcp-example",
60+
"author": "Coder",
61+
}
62+
encoder := json.NewEncoder(inv.Stdout)
63+
return encoder.Encode(version)
64+
},
65+
}
66+
root.AddSubcommands(versionCmd)
67+
68+
// Add a command that will not be exposed via MCP
69+
hiddenCmd := &serpent.Command{
70+
Use: "hidden",
71+
Short: "This command is not exposed via MCP",
72+
Handler: func(inv *serpent.Invocation) error {
73+
_, err := fmt.Fprintln(inv.Stdout, "This command is not exposed via MCP")
74+
return err
75+
},
76+
}
77+
root.AddSubcommands(hiddenCmd)
78+
79+
// Add the MCP command to the root command
80+
root.AddSubcommands(serpent.MCPCommand())
81+
82+
// Run the command
83+
if err := root.Invoke(os.Args[1:]...).WithOS().Run(); err != nil {
84+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
85+
os.Exit(1)
86+
}
87+
}

help.tpl

+8
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@
1717
{{" Aliases: "}} {{- joinStrings .}}
1818
{{- end }}
1919

20+
{{ with .Tool }}
21+
{{" MCP Tool: "}} {{- . }}
22+
{{- end }}
23+
24+
{{ with .Resource }}
25+
{{" MCP Resource: "}} {{- . }}
26+
{{- end }}
27+
2028
{{- with .Long}}
2129
{{"\n"}}
2230
{{- indent . 2}}

0 commit comments

Comments
 (0)