Skip to content

Commit 7b73808

Browse files
committed
Go port: MCP client (stdio, Streamable HTTP, legacy SSE), manager, example server, transport + live tests
1 parent d133148 commit 7b73808

9 files changed

Lines changed: 2367 additions & 0 deletions

File tree

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
// Command mcp-example-server is a minimal example MCP server used by the transport
2+
// tests. One binary, three transports:
3+
//
4+
// go run ./examples/mcp-example-server --stdio newline-delimited JSON-RPC on stdin/stdout
5+
// go run ./examples/mcp-example-server --http <port> Streamable HTTP on http://127.0.0.1:<port>/mcp
6+
// go run ./examples/mcp-example-server --sse <port> legacy HTTP+SSE: GET /sse, POST /messages?session_id=…
7+
//
8+
// Port 0 picks a free port; HTTP modes print `listening on <port>` as the first stdout line.
9+
// Tools: echo (text), add (read-only), fail (isError), slow (sleeps `ms`), listed
10+
// over two tools/list pages. Resource: example://greeting.
11+
package main
12+
13+
import (
14+
"bufio"
15+
"encoding/json"
16+
"fmt"
17+
"io"
18+
"net"
19+
"net/http"
20+
"os"
21+
"strconv"
22+
"strings"
23+
"sync"
24+
"sync/atomic"
25+
"time"
26+
)
27+
28+
type msg = map[string]any
29+
30+
func main() {
31+
args := os.Args[1:]
32+
port := 0
33+
if len(args) > 1 {
34+
port, _ = strconv.Atoi(args[1])
35+
}
36+
mode := ""
37+
if len(args) > 0 {
38+
mode = args[0]
39+
}
40+
switch mode {
41+
case "--stdio":
42+
stdio()
43+
case "--http":
44+
serve(port, streamableHandler())
45+
case "--sse":
46+
serve(port, sseHandler())
47+
default:
48+
fmt.Fprintln(os.Stderr, "usage: mcp-example-server --stdio | --http <port> | --sse <port>")
49+
os.Exit(2)
50+
}
51+
}
52+
53+
// ---- MCP logic (shared by all transports) -------------------------------------
54+
55+
type rpcErr struct {
56+
code int
57+
msg string
58+
}
59+
60+
// handle processes one JSON-RPC message; nil for notifications.
61+
func handle(m msg, transport string) msg {
62+
id, ok := m["id"]
63+
if !ok {
64+
return nil
65+
}
66+
method, _ := m["method"].(string)
67+
params, _ := m["params"].(map[string]any)
68+
var result any
69+
var e *rpcErr
70+
switch method {
71+
case "initialize":
72+
result = msg{
73+
"protocolVersion": "2024-11-05",
74+
"capabilities": msg{"tools": msg{}, "resources": msg{}},
75+
"serverInfo": msg{"name": "example-" + transport, "version": "1.0.0"},
76+
}
77+
case "tools/list":
78+
if _, ok := params["cursor"]; !ok {
79+
result = msg{"tools": []any{
80+
msg{"name": "echo", "description": "Echo back a message",
81+
"inputSchema": msg{"type": "object", "properties": msg{"message": msg{"type": "string"}}, "required": []any{"message"}}},
82+
msg{"name": "add", "description": "Add two numbers",
83+
"inputSchema": msg{"type": "object", "properties": msg{"a": msg{"type": "number"}, "b": msg{"type": "number"}}},
84+
"annotations": msg{"readOnlyHint": true}},
85+
msg{"name": "bad name!", "description": "invalid name, must be dropped by the client"},
86+
}, "nextCursor": "page2"}
87+
} else {
88+
result = msg{"tools": []any{
89+
msg{"name": "fail", "description": "Always reports a tool error"},
90+
msg{"name": "slow", "description": "Sleep then answer",
91+
"inputSchema": msg{"type": "object", "properties": msg{"ms": msg{"type": "integer"}}}},
92+
}}
93+
}
94+
case "tools/call":
95+
result, e = callTool(params)
96+
case "resources/list":
97+
result = msg{"resources": []any{msg{"uri": "example://greeting", "name": "Greeting", "mimeType": "text/plain"}}}
98+
case "resources/read":
99+
if uri, _ := params["uri"].(string); uri == "example://greeting" {
100+
result = msg{"contents": []any{msg{"uri": uri, "mimeType": "text/plain", "text": "Hello from " + transport + "!"}}}
101+
} else {
102+
e = &rpcErr{-32002, "resource not found: " + uri}
103+
}
104+
case "ping":
105+
result = msg{}
106+
default:
107+
e = &rpcErr{-32601, "method not found: " + method}
108+
}
109+
if e != nil {
110+
return msg{"jsonrpc": "2.0", "id": id, "error": msg{"code": e.code, "message": e.msg}}
111+
}
112+
return msg{"jsonrpc": "2.0", "id": id, "result": result}
113+
}
114+
115+
func callTool(params map[string]any) (any, *rpcErr) {
116+
args, _ := params["arguments"].(map[string]any)
117+
text := func(t string) any { return msg{"content": []any{msg{"type": "text", "text": t}}} }
118+
name, _ := params["name"].(string)
119+
switch name {
120+
case "echo":
121+
s, _ := args["message"].(string)
122+
return text(s), nil
123+
case "add":
124+
a, _ := args["a"].(float64)
125+
b, _ := args["b"].(float64)
126+
return text(strconv.FormatFloat(a+b, 'f', -1, 64)), nil
127+
case "fail":
128+
return msg{"content": []any{msg{"type": "text", "text": "this tool always fails"}}, "isError": true}, nil
129+
case "slow":
130+
ms := 100.0
131+
if v, ok := args["ms"].(float64); ok {
132+
ms = v
133+
}
134+
time.Sleep(time.Duration(ms) * time.Millisecond)
135+
return text(fmt.Sprintf("slept %dms", int(ms))), nil
136+
}
137+
return nil, &rpcErr{-32602, "unknown tool: " + name}
138+
}
139+
140+
// ---- stdio --------------------------------------------------------------------
141+
142+
func stdio() {
143+
fmt.Fprintln(os.Stderr, "example server: stdio ready") // exercises the client's stderr drain
144+
var mu sync.Mutex
145+
sc := bufio.NewScanner(os.Stdin)
146+
sc.Buffer(make([]byte, 1024*1024), 16*1024*1024)
147+
for sc.Scan() {
148+
var m msg
149+
if json.Unmarshal(sc.Bytes(), &m) != nil {
150+
continue
151+
}
152+
// Answer concurrently so replies can arrive out of order.
153+
go func() {
154+
if resp := handle(m, "stdio"); resp != nil {
155+
data, _ := json.Marshal(resp)
156+
mu.Lock()
157+
os.Stdout.Write(append(data, '\n'))
158+
mu.Unlock()
159+
}
160+
}()
161+
}
162+
}
163+
164+
// ---- HTTP -----------------------------------------------------------------------
165+
166+
func serve(port int, h http.Handler) {
167+
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
168+
if err != nil {
169+
fmt.Fprintln(os.Stderr, err)
170+
os.Exit(1)
171+
}
172+
fmt.Printf("listening on %d\n", ln.Addr().(*net.TCPAddr).Port)
173+
os.Stdout.Sync()
174+
http.Serve(ln, h)
175+
}
176+
177+
func readJSON(r *http.Request) (msg, bool) {
178+
var m msg
179+
body, _ := io.ReadAll(r.Body)
180+
return m, json.Unmarshal(body, &m) == nil
181+
}
182+
183+
func streamableHandler() http.Handler {
184+
var next atomic.Int64
185+
var mu sync.Mutex
186+
// sessions that were initialized and not DELETEd.
187+
sessions := map[string]bool{}
188+
mux := http.NewServeMux()
189+
mux.HandleFunc("/mcp", func(w http.ResponseWriter, r *http.Request) {
190+
switch r.Method {
191+
case http.MethodDelete:
192+
mu.Lock()
193+
delete(sessions, r.Header.Get("Mcp-Session-Id"))
194+
mu.Unlock()
195+
return
196+
case http.MethodPost:
197+
default:
198+
http.NotFound(w, r)
199+
return
200+
}
201+
m, ok := readJSON(r)
202+
if !ok {
203+
http.Error(w, "bad json", http.StatusBadRequest)
204+
return
205+
}
206+
method, _ := m["method"].(string)
207+
if method == "initialize" {
208+
sid := fmt.Sprintf("sess-%d", next.Add(1)-1)
209+
mu.Lock()
210+
sessions[sid] = true
211+
mu.Unlock()
212+
w.Header().Set("Mcp-Session-Id", sid)
213+
} else {
214+
mu.Lock()
215+
known := sessions[r.Header.Get("Mcp-Session-Id")]
216+
mu.Unlock()
217+
if !known {
218+
// Unknown / missing session → 404, which clients must treat as "re-initialize".
219+
http.Error(w, "session not found", http.StatusNotFound)
220+
return
221+
}
222+
}
223+
resp := handle(m, "http")
224+
if resp == nil {
225+
w.WriteHeader(http.StatusAccepted)
226+
return
227+
}
228+
data, _ := json.Marshal(resp)
229+
if method == "tools/call" && strings.Contains(r.Header.Get("Accept"), "text/event-stream") {
230+
// Stream a progress notification before the actual response, like real servers do.
231+
progress, _ := json.Marshal(msg{"jsonrpc": "2.0", "method": "notifications/progress", "params": msg{"progress": 1}})
232+
w.Header().Set("Content-Type", "text/event-stream")
233+
fmt.Fprintf(w, ": keepalive\n\nevent: message\ndata: %s\n\ndata: %s\n\n", progress, data)
234+
return
235+
}
236+
w.Header().Set("Content-Type", "application/json")
237+
w.Write(data)
238+
})
239+
return mux
240+
}
241+
242+
func sseHandler() http.Handler {
243+
var next atomic.Int64
244+
var mu sync.Mutex
245+
// streams: session id → channel feeding that client's GET stream.
246+
streams := map[string]chan string{}
247+
mux := http.NewServeMux()
248+
stream := func(w http.ResponseWriter, r *http.Request) {
249+
if r.Method != http.MethodGet {
250+
http.NotFound(w, r)
251+
return
252+
}
253+
sid := fmt.Sprintf("s%d", next.Add(1)-1)
254+
ch := make(chan string, 64)
255+
mu.Lock()
256+
streams[sid] = ch
257+
mu.Unlock()
258+
defer func() {
259+
mu.Lock()
260+
delete(streams, sid)
261+
mu.Unlock()
262+
}()
263+
w.Header().Set("Content-Type", "text/event-stream")
264+
w.Header().Set("Cache-Control", "no-cache")
265+
fmt.Fprintf(w, "event: endpoint\ndata: /messages?session_id=%s\n\n", sid)
266+
w.(http.Flusher).Flush()
267+
for {
268+
select {
269+
case ev := <-ch:
270+
if _, err := io.WriteString(w, ev); err != nil {
271+
return
272+
}
273+
w.(http.Flusher).Flush()
274+
case <-r.Context().Done(): // client hung up
275+
return
276+
}
277+
}
278+
}
279+
mux.HandleFunc("/sse", stream)
280+
// /events lets tests check that "transport": "sse" forces the legacy transport.
281+
mux.HandleFunc("/events", stream)
282+
mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
283+
if r.Method != http.MethodPost {
284+
http.NotFound(w, r)
285+
return
286+
}
287+
mu.Lock()
288+
ch, ok := streams[r.URL.Query().Get("session_id")]
289+
mu.Unlock()
290+
if !ok {
291+
http.Error(w, "unknown session", http.StatusNotFound)
292+
return
293+
}
294+
m, ok := readJSON(r)
295+
if !ok {
296+
http.Error(w, "bad json", http.StatusBadRequest)
297+
return
298+
}
299+
w.WriteHeader(http.StatusAccepted)
300+
// The reply travels back over the GET stream, not this response.
301+
go func() {
302+
if resp := handle(m, "sse"); resp != nil {
303+
data, _ := json.Marshal(resp)
304+
ch <- fmt.Sprintf("event: message\ndata: %s\n\n", data)
305+
}
306+
}()
307+
})
308+
return mux
309+
}

0 commit comments

Comments
 (0)