Skip to content

Commit cc6bc88

Browse files
committed
internal/mcp: an MCP SDK prototype
This CL contains an minimal prototype of an MCP SDK, using the "new" jsonrpc2_v2 package (similar to x/exp/jsonrpc2). Much is still yet to do, but this initial version addressed the following aspects: - Support for newline delimited JSONRPC2 framing, and message logging. - A generated protocol package, containing type definitions to be used in transport. - A minimal jsonschema package to be used for both reading the MCP spec, and for serving Tool input schemas. - A transport abstraction, and two transport implementations: stdio, and local (for testing). - Client and Server types, to be configured with features and then connected using their Connect methods, which return (respectively) a ServerConnection and ClientConnection object. - A minimal binding API for tools. A catalog of things not yet done is in doc.go, but we should review this CL eagerly, as it contains the fundamental building blocks of an eventual SDK. It is the intention that this package is more-or-less standalone, as it may eventually be carved out into a separate repository. As such, its only dependency within x/tools is jsonrpc2_v2, and the only change required to that package is to expose a callback when the connection is closed. Change-Id: Ib66018961014f85a8884d56ab721d5642c98443c Reviewed-on: https://go-review.googlesource.com/c/tools/+/667036 Reviewed-by: Jonathan Amsterdam <[email protected]> LUCI-TryBot-Result: Go LUCI <[email protected]>
1 parent e9d2a36 commit cc6bc88

File tree

20 files changed

+1882
-13
lines changed

20 files changed

+1882
-13
lines changed

gopls/internal/lsprpc/binder_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ func (e *TestEnv) dial(ctx context.Context, t *testing.T, dialer jsonrpc2_v2.Dia
105105
l, _ := e.serve(ctx, t, NewForwardBinder(dialer))
106106
dialer = l.Dialer()
107107
}
108-
conn, err := jsonrpc2_v2.Dial(ctx, dialer, client)
108+
conn, err := jsonrpc2_v2.Dial(ctx, dialer, client, nil)
109109
if err != nil {
110110
t.Fatal(err)
111111
}

gopls/internal/lsprpc/export_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func (b *ForwardBinder) Bind(ctx context.Context, conn *jsonrpc2_v2.Connection)
6262
client := protocol.ClientDispatcherV2(conn)
6363
clientBinder := NewClientBinder(func(context.Context, protocol.Server) protocol.Client { return client })
6464

65-
serverConn, err := jsonrpc2_v2.Dial(context.Background(), b.dialer, clientBinder)
65+
serverConn, err := jsonrpc2_v2.Dial(context.Background(), b.dialer, clientBinder, nil)
6666
if err != nil {
6767
return jsonrpc2_v2.ConnectionOptions{
6868
Handler: jsonrpc2_v2.HandlerFunc(func(context.Context, *jsonrpc2_v2.Request) (any, error) {

internal/jsonrpc2_v2/frame.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ type Writer interface {
4141
// It is responsible for the framing and encoding of messages into wire form.
4242
type Framer interface {
4343
// Reader wraps a byte reader into a message reader.
44-
Reader(rw io.Reader) Reader
44+
Reader(io.Reader) Reader
4545
// Writer wraps a byte writer into a message writer.
46-
Writer(rw io.Writer) Writer
46+
Writer(io.Writer) Writer
4747
}
4848

4949
// RawFramer returns a new Framer.

internal/jsonrpc2_v2/jsonrpc2_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ func testConnection(t *testing.T, framer jsonrpc2.Framer) {
153153
// also run all simple call tests in echo mode
154154
(*echo)(call).Invoke(t, ctx, h)
155155
}
156-
}})
156+
}}, nil)
157157
if err != nil {
158158
t.Fatal(err)
159159
}

internal/jsonrpc2_v2/serve.go

+4-2
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,15 @@ type Server struct {
5454
// Handler provided by the Binder, and will release its own resources when the
5555
// connection is broken, but the caller may Close it earlier to stop accepting
5656
// (or sending) new requests.
57-
func Dial(ctx context.Context, dialer Dialer, binder Binder) (*Connection, error) {
57+
//
58+
// If non-nil, the onDone function is called when the connection is closed.
59+
func Dial(ctx context.Context, dialer Dialer, binder Binder, onDone func()) (*Connection, error) {
5860
// dial a server
5961
rwc, err := dialer.Dial(ctx)
6062
if err != nil {
6163
return nil, err
6264
}
63-
return newConnection(ctx, rwc, binder, nil), nil
65+
return newConnection(ctx, rwc, binder, onDone), nil
6466
}
6567

6668
// NewServer starts a new server listening for incoming connections and returns

internal/jsonrpc2_v2/serve_test.go

+6-6
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func TestIdleTimeout(t *testing.T) {
4747

4848
// Exercise some connection/disconnection patterns, and then assert that when
4949
// our timer fires, the server exits.
50-
conn1, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{})
50+
conn1, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil)
5151
if err != nil {
5252
if since := time.Since(idleStart); since < d {
5353
t.Fatalf("conn1 failed to connect after %v: %v", since, err)
@@ -71,7 +71,7 @@ func TestIdleTimeout(t *testing.T) {
7171
// Since conn1 was successfully accepted and remains open, the server is
7272
// definitely non-idle. Dialing another simultaneous connection should
7373
// succeed.
74-
conn2, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{})
74+
conn2, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil)
7575
if err != nil {
7676
conn1.Close()
7777
t.Fatalf("conn2 failed to connect while non-idle after %v: %v", time.Since(idleStart), err)
@@ -96,7 +96,7 @@ func TestIdleTimeout(t *testing.T) {
9696
t.Fatalf("conn2.Close failed with error: %v", err)
9797
}
9898

99-
conn3, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{})
99+
conn3, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil)
100100
if err != nil {
101101
if since := time.Since(idleStart); since < d {
102102
t.Fatalf("conn3 failed to connect after %v: %v", since, err)
@@ -205,7 +205,7 @@ func newFake(t *testing.T, ctx context.Context, l jsonrpc2.Listener) (*jsonrpc2.
205205
l.Dialer(),
206206
jsonrpc2.ConnectionOptions{
207207
Handler: fakeHandler{},
208-
})
208+
}, nil)
209209
if err != nil {
210210
return nil, nil, err
211211
}
@@ -250,7 +250,7 @@ func TestIdleListenerAcceptCloseRace(t *testing.T) {
250250

251251
done := make(chan struct{})
252252
go func() {
253-
conn, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{})
253+
conn, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil)
254254
listener.Close()
255255
if err == nil {
256256
conn.Close()
@@ -313,7 +313,7 @@ func TestCloseCallRace(t *testing.T) {
313313
return jsonrpc2.ConnectionOptions{Handler: h}
314314
}))
315315

316-
dialConn, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{})
316+
dialConn, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil)
317317
if err != nil {
318318
listener.Close()
319319
s.Wait()

internal/mcp/client.go

+191
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Copyright 2025 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package mcp
6+
7+
import (
8+
"context"
9+
"encoding/json"
10+
"errors"
11+
"fmt"
12+
"iter"
13+
"slices"
14+
"sync"
15+
16+
jsonrpc2 "golang.org/x/tools/internal/jsonrpc2_v2"
17+
"golang.org/x/tools/internal/mcp/internal/protocol"
18+
)
19+
20+
// A Client is an MCP client, which may be connected to one or more MCP servers
21+
// using the [Client.Connect] method.
22+
//
23+
// TODO(rfindley): revisit the many-to-one relationship of clients and servers.
24+
// It is a bit odd.
25+
type Client struct {
26+
name string
27+
version string
28+
29+
mu sync.Mutex
30+
servers []*ServerConnection
31+
}
32+
33+
// NewClient creates a new Client.
34+
//
35+
// Use [Client.Connect] to connect it to an MCP server.
36+
//
37+
// If non-nil, the provided options configure the Client.
38+
func NewClient(name, version string, opts *ClientOptions) *Client {
39+
return &Client{
40+
name: name,
41+
version: version,
42+
}
43+
}
44+
45+
// Servers returns an iterator that yields the current set of server
46+
// connections.
47+
func (c *Client) Servers() iter.Seq[*ServerConnection] {
48+
c.mu.Lock()
49+
clients := slices.Clone(c.servers)
50+
c.mu.Unlock()
51+
return slices.Values(clients)
52+
}
53+
54+
// ClientOptions configures the behavior of the client, and apply to every
55+
// client-server connection created using [Client.Connect].
56+
type ClientOptions struct{}
57+
58+
// bind implements the binder[*ServerConnection] interface, so that Clients can
59+
// be connected using [connect].
60+
func (c *Client) bind(conn *jsonrpc2.Connection) *ServerConnection {
61+
sc := &ServerConnection{
62+
conn: conn,
63+
client: c,
64+
}
65+
c.mu.Lock()
66+
c.servers = append(c.servers, sc)
67+
c.mu.Unlock()
68+
return sc
69+
}
70+
71+
// disconnect implements the binder[*ServerConnection] interface, so that
72+
// Clients can be connected using [connect].
73+
func (c *Client) disconnect(sc *ServerConnection) {
74+
c.mu.Lock()
75+
defer c.mu.Unlock()
76+
c.servers = slices.DeleteFunc(c.servers, func(sc2 *ServerConnection) bool {
77+
return sc2 == sc
78+
})
79+
}
80+
81+
// Connect connects the MCP client over the given transport and initializes an
82+
// MCP session.
83+
//
84+
// It returns a connection object that may be used to query the MCP server,
85+
// terminate the connection (with [Connection.Close]), or await server
86+
// termination (with [Connection.Wait]).
87+
//
88+
// Typically, it is the responsibility of the client to close the connection
89+
// when it is no longer needed. However, if the connection is closed by the
90+
// server, calls or notifications will return an error wrapping
91+
// [ErrConnectionClosed].
92+
func (c *Client) Connect(ctx context.Context, t *Transport, opts *ConnectionOptions) (sc *ServerConnection, err error) {
93+
defer func() {
94+
if sc != nil && err != nil {
95+
_ = sc.Close()
96+
}
97+
}()
98+
sc, err = connect(ctx, t, opts, c)
99+
if err != nil {
100+
return nil, err
101+
}
102+
params := &protocol.InitializeParams{
103+
ClientInfo: protocol.Implementation{Name: c.name, Version: c.version},
104+
}
105+
if err := call(ctx, sc.conn, "initialize", params, &sc.initializeResult); err != nil {
106+
return nil, err
107+
}
108+
if err := sc.conn.Notify(ctx, "initialized", &protocol.InitializedParams{}); err != nil {
109+
return nil, err
110+
}
111+
return sc, nil
112+
}
113+
114+
// A ServerConnection is a connection with an MCP server.
115+
//
116+
// It handles messages from the client, and can be used to send messages to the
117+
// client. Create a connection by calling [Server.Connect].
118+
type ServerConnection struct {
119+
conn *jsonrpc2.Connection
120+
client *Client
121+
initializeResult *protocol.InitializeResult
122+
}
123+
124+
// Close performs a graceful close of the connection, preventing new requests
125+
// from being handled, and waiting for ongoing requests to return. Close then
126+
// terminates the connection.
127+
func (cc *ServerConnection) Close() error {
128+
return cc.conn.Close()
129+
}
130+
131+
// Wait waits for the connection to be closed by the server.
132+
// Generally, clients should be responsible for closing the connection.
133+
func (cc *ServerConnection) Wait() error {
134+
return cc.conn.Wait()
135+
}
136+
137+
func (sc *ServerConnection) handle(ctx context.Context, req *jsonrpc2.Request) (any, error) {
138+
switch req.Method {
139+
}
140+
return nil, jsonrpc2.ErrNotHandled
141+
}
142+
143+
// ListTools lists tools that are currently available on the server.
144+
func (sc *ServerConnection) ListTools(ctx context.Context) ([]protocol.Tool, error) {
145+
var (
146+
params = &protocol.ListToolsParams{}
147+
result protocol.ListToolsResult
148+
)
149+
if err := call(ctx, sc.conn, "tools/list", params, &result); err != nil {
150+
return nil, err
151+
}
152+
return result.Tools, nil
153+
}
154+
155+
// CallTool calls the tool with the given name and arguments.
156+
//
157+
// TODO: make the following true:
158+
// If the provided arguments do not conform to the schema for the given tool,
159+
// the call fails.
160+
func (sc *ServerConnection) CallTool(ctx context.Context, name string, args any) (_ []Content, err error) {
161+
defer func() {
162+
if err != nil {
163+
err = fmt.Errorf("calling tool %q: %w", name, err)
164+
}
165+
}()
166+
argJSON, err := json.Marshal(args)
167+
if err != nil {
168+
return nil, fmt.Errorf("marshaling args: %v", err)
169+
}
170+
var (
171+
params = &protocol.CallToolParams{
172+
Name: name,
173+
Arguments: argJSON,
174+
}
175+
result protocol.CallToolResult
176+
)
177+
if err := call(ctx, sc.conn, "tools/call", params, &result); err != nil {
178+
return nil, err
179+
}
180+
content, err := unmarshalContent(result.Content)
181+
if err != nil {
182+
return nil, fmt.Errorf("unmarshaling tool content: %v", err)
183+
}
184+
if result.IsError {
185+
if len(content) != 1 || !is[TextContent](content[0]) {
186+
return nil, errors.New("malformed error content")
187+
}
188+
return nil, errors.New(content[0].(TextContent).Text)
189+
}
190+
return content, nil
191+
}

internal/mcp/content.go

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright 2025 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package mcp
6+
7+
import (
8+
"encoding/json"
9+
"fmt"
10+
11+
"golang.org/x/tools/internal/mcp/internal/protocol"
12+
)
13+
14+
// Content is the abstract result of a Tool call.
15+
//
16+
// TODO: support all content types.
17+
type Content interface {
18+
toProtocol() any
19+
}
20+
21+
func marshalContent(content []Content) []json.RawMessage {
22+
var msgs []json.RawMessage
23+
for _, c := range content {
24+
msg, err := json.Marshal(c.toProtocol())
25+
if err != nil {
26+
panic(fmt.Sprintf("marshaling content: %v", err))
27+
}
28+
msgs = append(msgs, msg)
29+
}
30+
return msgs
31+
}
32+
33+
func unmarshalContent(msgs []json.RawMessage) ([]Content, error) {
34+
var content []Content
35+
for _, msg := range msgs {
36+
var allContent struct {
37+
Type string `json:"type"`
38+
Text json.RawMessage
39+
}
40+
if err := json.Unmarshal(msg, &allContent); err != nil {
41+
return nil, fmt.Errorf("content missing \"type\"")
42+
}
43+
switch allContent.Type {
44+
case "text":
45+
var text string
46+
if err := json.Unmarshal(allContent.Text, &text); err != nil {
47+
return nil, fmt.Errorf("unmarshalling text content: %v", err)
48+
}
49+
content = append(content, TextContent{Text: text})
50+
default:
51+
return nil, fmt.Errorf("unsupported content type %q", allContent.Type)
52+
}
53+
}
54+
return content, nil
55+
}
56+
57+
// TextContent is a textual content.
58+
type TextContent struct {
59+
Text string
60+
}
61+
62+
func (c TextContent) toProtocol() any {
63+
return protocol.TextContent{Type: "text", Text: c.Text}
64+
}

0 commit comments

Comments
 (0)