diff --git a/examples/hello/main.go b/examples/hello/main.go new file mode 100644 index 0000000..bc75e82 --- /dev/null +++ b/examples/hello/main.go @@ -0,0 +1,38 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package main + +import ( + "context" + "fmt" + "os" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type Optional[T any] struct { + present bool + value T +} + +type SayHiParams struct { + Name string `json:"name" mcp:"the name to say hi to"` +} + +func SayHi(ctx context.Context, params *SayHiParams) ([]mcp.Content, error) { + return []mcp.Content{ + mcp.TextContent{Text: "Hi " + params.Name}, + }, nil +} + +func main() { + server := mcp.NewServer("greeter", "v0.0.1", nil) + server.AddTools(mcp.MakeTool("greet", "say hi", SayHi)) + + opts := &mcp.ConnectionOptions{Logger: os.Stderr} + if err := server.Run(context.Background(), mcp.NewStdIOTransport(), opts); err != nil { + fmt.Fprintf(os.Stderr, "Server failed: %v", err) + } +} diff --git a/internal/jsonrpc2/frame.go b/internal/jsonrpc2/frame.go index fc14ff2..b73fec1 100644 --- a/internal/jsonrpc2/frame.go +++ b/internal/jsonrpc2/frame.go @@ -41,9 +41,9 @@ type Writer interface { // It is responsible for the framing and encoding of messages into wire form. type Framer interface { // Reader wraps a byte reader into a message reader. - Reader(rw io.Reader) Reader + Reader(io.Reader) Reader // Writer wraps a byte writer into a message writer. - Writer(rw io.Writer) Writer + Writer(io.Writer) Writer } // RawFramer returns a new Framer. diff --git a/internal/jsonrpc2/jsonrpc2_test.go b/internal/jsonrpc2/jsonrpc2_test.go index 11f57cf..75124cd 100644 --- a/internal/jsonrpc2/jsonrpc2_test.go +++ b/internal/jsonrpc2/jsonrpc2_test.go @@ -153,7 +153,7 @@ func testConnection(t *testing.T, framer jsonrpc2.Framer) { // also run all simple call tests in echo mode (*echo)(call).Invoke(t, ctx, h) } - }}) + }}, nil) if err != nil { t.Fatal(err) } diff --git a/internal/jsonrpc2/serve.go b/internal/jsonrpc2/serve.go index 9561e19..e941c57 100644 --- a/internal/jsonrpc2/serve.go +++ b/internal/jsonrpc2/serve.go @@ -54,13 +54,15 @@ type Server struct { // Handler provided by the Binder, and will release its own resources when the // connection is broken, but the caller may Close it earlier to stop accepting // (or sending) new requests. -func Dial(ctx context.Context, dialer Dialer, binder Binder) (*Connection, error) { +// +// If non-nil, the onDone function is called when the connection is closed. +func Dial(ctx context.Context, dialer Dialer, binder Binder, onDone func()) (*Connection, error) { // dial a server rwc, err := dialer.Dial(ctx) if err != nil { return nil, err } - return newConnection(ctx, rwc, binder, nil), nil + return newConnection(ctx, rwc, binder, onDone), nil } // NewServer starts a new server listening for incoming connections and returns diff --git a/internal/jsonrpc2/serve_test.go b/internal/jsonrpc2/serve_test.go index 8169af2..31f2c16 100644 --- a/internal/jsonrpc2/serve_test.go +++ b/internal/jsonrpc2/serve_test.go @@ -47,7 +47,7 @@ func TestIdleTimeout(t *testing.T) { // Exercise some connection/disconnection patterns, and then assert that when // our timer fires, the server exits. - conn1, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}) + conn1, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil) if err != nil { if since := time.Since(idleStart); since < d { t.Fatalf("conn1 failed to connect after %v: %v", since, err) @@ -71,7 +71,7 @@ func TestIdleTimeout(t *testing.T) { // Since conn1 was successfully accepted and remains open, the server is // definitely non-idle. Dialing another simultaneous connection should // succeed. - conn2, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}) + conn2, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil) if err != nil { conn1.Close() 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) { t.Fatalf("conn2.Close failed with error: %v", err) } - conn3, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}) + conn3, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil) if err != nil { if since := time.Since(idleStart); since < d { 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. l.Dialer(), jsonrpc2.ConnectionOptions{ Handler: fakeHandler{}, - }) + }, nil) if err != nil { return nil, nil, err } @@ -250,7 +250,7 @@ func TestIdleListenerAcceptCloseRace(t *testing.T) { done := make(chan struct{}) go func() { - conn, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}) + conn, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil) listener.Close() if err == nil { conn.Close() @@ -313,7 +313,7 @@ func TestCloseCallRace(t *testing.T) { return jsonrpc2.ConnectionOptions{Handler: h} })) - dialConn, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}) + dialConn, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil) if err != nil { listener.Close() s.Wait() diff --git a/internal/jsonschema/schema.go b/internal/jsonschema/schema.go new file mode 100644 index 0000000..ca6923d --- /dev/null +++ b/internal/jsonschema/schema.go @@ -0,0 +1,130 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package jsonschema + +import ( + "fmt" + "reflect" + "strings" +) + +// A Schema is a JSON schema object. +// +// Right now, Schemas are only used for JSON serialization. In the future, they +// should support validation. +type Schema struct { + Definitions map[string]*Schema `json:"definitions"` + Type any `json:"type,omitempty"` + Ref string `json:"$ref,omitempty"` + Description string `json:"description,omitempty"` + Properties map[string]*Schema `json:"properties,omitempty"` + Required []string `json:"required,omitempty"` + Items *Schema `json:"items,omitempty"` + AdditionalProperties any `json:"additionalProperties,omitempty"` +} + +// ForType constructs a JSON schema object for the given type argument. +// +// The type T must not contain (possibly recursively) any of the following Go +// types, as they are incompatible with the JSON schema spec. +// - maps with key other than 'string' +// - function types +// - complex numbers +// - unsafe pointers +// +// TODO(rfindley): we could perhaps just skip these incompatible fields. +func ForType[T any]() (*Schema, error) { + return typeSchema(reflect.TypeFor[T](), make(map[reflect.Type]*Schema)) +} + +func typeSchema(t reflect.Type, seen map[reflect.Type]*Schema) (*Schema, error) { + if s := seen[t]; s != nil { + return s, nil + } + var ( + s = new(Schema) + err error + ) + seen[t] = s + + switch t.Kind() { + case reflect.Bool: + s.Type = "boolean" + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + s.Type = "integer" + + case reflect.Float32, reflect.Float64: + s.Type = "number" + + case reflect.Interface: + // Unrestricted + + case reflect.Map: + if t.Key().Kind() != reflect.String { + return nil, fmt.Errorf("unsupported map key type %v", t.Key().Kind()) + } + s.Type = "object" + valueSchema, err := typeSchema(t.Elem(), seen) + if err != nil { + return nil, fmt.Errorf("computing map value schema: %v", err) + } + s.AdditionalProperties = valueSchema + + case reflect.Pointer: + s2, err := typeSchema(t.Elem(), seen) + if err != nil { + return nil, err + } + *s = *s2 + + case reflect.Slice, reflect.Array: + s.Type = "array" + itemSchema, err := typeSchema(t.Elem(), seen) + if err != nil { + return nil, fmt.Errorf("computing element schema: %v", err) + } + s.Items = itemSchema + + case reflect.String: + s.Type = "string" + + case reflect.Struct: + s.Type = "object" + s.AdditionalProperties = false + + for i := range t.NumField() { + if s.Properties == nil { + s.Properties = make(map[string]*Schema) + } + rfld := t.Field(i) + name, ok := jsonName(rfld) + if !ok { + continue + } + s.Properties[name], err = typeSchema(rfld.Type, seen) + if err != nil { + return nil, err + } + } + + default: + return nil, fmt.Errorf("type %v is unsupported by jsonschema", t.Kind()) + } + return s, nil +} + +func jsonName(f reflect.StructField) (string, bool) { + j, ok := f.Tag.Lookup("json") + if !ok { + return f.Name, f.IsExported() + } + name, _, _ := strings.Cut(j, ",") + if name == "" { + return f.Name, f.IsExported() + } + return name, name != "" && name != "-" +} diff --git a/internal/jsonschema/schema_test.go b/internal/jsonschema/schema_test.go new file mode 100644 index 0000000..302c9b7 --- /dev/null +++ b/internal/jsonschema/schema_test.go @@ -0,0 +1,65 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package jsonschema_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/modelcontextprotocol/go-sdk/internal/jsonschema" +) + +func forType[T any]() *jsonschema.Schema { + s, err := jsonschema.ForType[T]() + if err != nil { + panic(err) + } + return s +} + +func TestForType(t *testing.T) { + type schema = jsonschema.Schema + tests := []struct { + name string + got *jsonschema.Schema + want *jsonschema.Schema + }{ + {"string", forType[string](), &schema{Type: "string"}}, + {"int", forType[int](), &schema{Type: "integer"}}, + {"int16", forType[int16](), &schema{Type: "integer"}}, + {"uint32", forType[int16](), &schema{Type: "integer"}}, + {"float64", forType[float64](), &schema{Type: "number"}}, + {"bool", forType[bool](), &schema{Type: "boolean"}}, + {"intmap", forType[map[string]int](), &schema{ + Type: "object", + AdditionalProperties: &schema{Type: "integer"}, + }}, + {"anymap", forType[map[string]any](), &schema{ + Type: "object", + AdditionalProperties: &schema{}, + }}, + {"struct", forType[struct { + F int `json:"f"` + G []float64 + Skip string `json:"-"` + unexported float64 + }](), &schema{ + Type: "object", + Properties: map[string]*schema{ + "f": {Type: "integer"}, + "G": {Type: "array", Items: &schema{Type: "number"}}, + }, + AdditionalProperties: false, + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if diff := cmp.Diff(test.want, test.got); diff != "" { + t.Errorf("ForType mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/protocol/doc.go b/internal/protocol/doc.go new file mode 100644 index 0000000..4109d43 --- /dev/null +++ b/internal/protocol/doc.go @@ -0,0 +1,13 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:generate go run generate.go + +// The protocol package contains types that define the MCP protocol. +// +// It is auto-generated from the MCP spec. Run go generate to update it. +// The generated set of types is intended to be minimal, in the sense that we +// only generate types that are actually used by the SDK. See generate.go for +// instructions on how to generate more (or different) types. +package protocol diff --git a/internal/protocol/generate.go b/internal/protocol/generate.go new file mode 100644 index 0000000..4e903df --- /dev/null +++ b/internal/protocol/generate.go @@ -0,0 +1,400 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build ignore + +// This script generates protocol definitions in protocol.go from the MCP spec. +// +// Only the set of declarations configured by the [declarations] value are +// generated. + +package main + +import ( + "bytes" + "cmp" + "encoding/json" + "flag" + "fmt" + "go/format" + "io" + "iter" + "log" + "net/http" + "os" + "slices" + "strings" + + "github.com/modelcontextprotocol/go-sdk/internal/jsonschema" +) + +var schemaFile = flag.String("schema_file", "", "if set, use this file as the persistent schema file") + +// A typeConfig defines a rewrite to perform to a (possibly nested) struct +// field. In some cases, we may want to use an external type for the nested +// struct field. In others, we may want to extract the type definition to a +// name. +type typeConfig struct { + Name string // declaration name for the type + Substitute string // type definition to substitute + Fields config // individual field configuration, or nil +} + +type config map[string]*typeConfig + +// declarations configures the set of declarations to write. +// +// Top level declarations are only created if they are configured with a +// non-empty Name. Otherwise, they are discarded, though their fields may be +// extracted to types if they have a nested field configuration. +var declarations = config{ + "Annotations": {Name: "Annotations"}, + "CallToolRequest": { + Fields: config{"Params": {Name: "CallToolParams"}}, + }, + "CallToolResult": { + Name: "CallToolResult", + }, + "ClientCapabilities": {Name: "ClientCapabilities"}, + "Implementation": {Name: "Implementation"}, + "InitializeRequest": { + Fields: config{"Params": {Name: "InitializeParams"}}, + }, + "InitializeResult": { + Name: "InitializeResult", + }, + "InitializedNotification": { + Fields: config{"Params": {Name: "InitializedParams"}}, + }, + "ListToolsRequest": { + Fields: config{"Params": {Name: "ListToolsParams"}}, + }, + "ListToolsResult": { + Name: "ListToolsResult", + }, + "Role": {Name: "Role"}, + "ServerCapabilities": { + Name: "ServerCapabilities", + Fields: config{ + "Prompts": {Name: "PromptCapabilities"}, + "Resources": {Name: "ResourceCapabilities"}, + "Tools": {Name: "ToolCapabilities"}, + }, + }, + "TextContent": {Name: "TextContent"}, + "Tool": { + Name: "Tool", + Fields: config{"InputSchema": {Substitute: "*jsonschema.Schema"}}, + }, + "ToolAnnotations": { + Name: "ToolAnnotations", + }, +} + +func main() { + flag.Parse() + + // Load and unmarshal the schema. + data, err := loadSchema(*schemaFile) + if err != nil { + log.Fatal(err) + } + schema := new(jsonschema.Schema) + if err := json.Unmarshal(data, &schema); err != nil { + log.Fatal(err) + } + + // Collect named types. Since we may create new type definitions while + // writing types, we collect definitions and concatenate them later. This + // also allows us to sort. + named := make(map[string]*bytes.Buffer) + for name, def := range sorted(schema.Definitions) { + config := declarations[name] + if config == nil { + continue + } + if err := writeDecl(*config, def, named); err != nil { + log.Fatal(err) + } + } + + buf := new(bytes.Buffer) + fmt.Fprintf(buf, ` +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// Code generated by generate.go. DO NOT EDIT. + +package protocol + +import ( + "encoding/json" + + "github.com/modelcontextprotocol/go-sdk/internal/jsonschema" +) +`) + + // Write out types. + for _, b := range sorted(named) { + fmt.Fprintln(buf) + fmt.Fprint(buf, b.String()) + } + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + log.Println(buf.String()) + log.Fatalf("failed to format: %v", err) + } + if err := os.WriteFile("protocol.go", formatted, 0666); err != nil { + log.Fatalf("failed to write protocol.go: %v", err) + } +} + +func loadSchema(schemaFile string) (data []byte, err error) { + const schemaURL = "https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/heads/main/schema/2025-03-26/schema.json" + + if schemaFile != "" { + data, err = os.ReadFile(schemaFile) + if os.IsNotExist(err) { + data = nil + } else if err != nil { + return nil, fmt.Errorf("reading schema file %q: %v", schemaFile, err) + } + } + if data == nil { + resp, err := http.Get(schemaURL) + if err != nil { + return nil, fmt.Errorf("downloading schema: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("downloading schema: %v", resp.Status) + } + data, err = io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading schema body: %v", err) + } + if schemaFile != "" { + if err := os.WriteFile(schemaFile, data, 0666); err != nil { + return nil, fmt.Errorf("persisting schema: %v", err) + } + } + } + return data, nil +} + +func writeDecl(config typeConfig, def *jsonschema.Schema, named map[string]*bytes.Buffer) error { + var w io.Writer = io.Discard + if name := config.Name; name != "" { + if _, ok := named[name]; ok { + return nil + } + buf := new(bytes.Buffer) + w = buf + named[name] = buf + if def.Description != "" { + fmt.Fprintf(buf, "%s\n", toComment(def.Description)) + } + fmt.Fprintf(buf, "type %s ", name) + } + if err := writeType(w, &config, def, named); err != nil { + return err // Better error here? + } + fmt.Fprintf(w, "\n") + return nil +} + +// writeType writes the type definition to the given writer. +// +// If path is non-empty, it is the path to the field using this type, for the +// purpose of detecting field rewrites (see [fieldRewrite]). +// +// named is the in-progress collection of type definitions. New named types may +// be added during writeType, if they are extracted from inner fields. +func writeType(w io.Writer, config *typeConfig, def *jsonschema.Schema, named map[string]*bytes.Buffer) error { + // Use type names for Named types. + if name := strings.TrimPrefix(def.Ref, "#/definitions/"); name != "" { + // TODO: this check is not quite right: we should really panic if the + // definition is missing, *but only if w is not io.Discard*. That's not a + // great API: see if we can do something more explicit than io.Discard. + if cfg, ok := declarations[name]; ok { + if cfg.Name == "" && cfg.Substitute == "" { + panic(fmt.Sprintf("referenced type %q cannot be referred to (no name or substitution)", name)) + } + if cfg.Substitute != "" { + name = cfg.Substitute + } else { + name = cfg.Name + } + } + w.Write([]byte(name)) + return nil + } + + // For types that explicitly allow additional properties, we can either + // unmarshal them into a map[string]any, or delay unmarshalling with + // json.RawMessage. For now, use json.RawMessage as it defers the choice. + if def.Type == "object" && def.AdditionalProperties != nil && def.AdditionalProperties != false { + w.Write([]byte("json.RawMessage")) + return nil + } + + switch typ := def.Type.(type) { + case string: + switch typ { + case "array": + fmt.Fprintf(w, "[]") + return writeType(w, nil, def.Items, named) + + case "boolean": + fmt.Fprintf(w, "bool") + + case "integer": + fmt.Fprintf(w, "int64") + + // not handled: "null" + + case "number": + // We could use json.Number here; use float64 for simplicity. + fmt.Fprintf(w, "float64") + + case "object": + fmt.Fprintf(w, "struct {\n") + for name, fieldDef := range sorted(def.Properties) { + if fieldDef.Description != "" { + fmt.Fprintf(w, "%s\n", toComment(fieldDef.Description)) + } + export := exportName(name) + fmt.Fprintf(w, "\t%s ", export) + + required := slices.Contains(def.Required, name) + + // If the field is not required, and is a struct type, indirect with a + // pointer so that it can be empty as defined by encoding/json. + // + // TODO: use omitzero when available. + needPointer := !required && + (strings.HasPrefix(fieldDef.Ref, "#/definitions/") || + fieldDef.Type == "object" && + (fieldDef.AdditionalProperties == nil || fieldDef.AdditionalProperties == false)) + + if config != nil && config.Fields[export] != nil { + r := config.Fields[export] + if r.Substitute != "" { + fmt.Fprintf(w, r.Substitute) + } else { + assert(r.Name != "", "missing ExtractTo") + if err := writeDecl(*r, fieldDef, named); err != nil { + return err + } + if needPointer { + fmt.Fprintf(w, "*") + } + fmt.Fprintf(w, r.Name) + } + } else { + if needPointer { + fmt.Fprintf(w, "*") + } + if err := writeType(w, nil, fieldDef, named); err != nil { + return fmt.Errorf("failed to write type for field %s: %v", export, err) + } + } + fmt.Fprintf(w, " `json:\"%s", name) + if !required { + fmt.Fprint(w, ",omitempty") + } + fmt.Fprint(w, "\"`\n") + } + fmt.Fprintf(w, "}") + + case "string": + fmt.Fprintf(w, "string") + + default: + fmt.Fprintf(w, "any") + } + + default: + // E.g. union types. + fmt.Fprintf(w, "json.RawMessage") + } + return nil +} + +// toComment converts a JSON schema description to a Go comment. +func toComment(description string) string { + var ( + buf strings.Builder + lineBuf strings.Builder + ) + const wrapAt = 80 + for line := range strings.SplitSeq(description, "\n") { + // Start a new paragraph, if the current is nonempty. + if len(line) == 0 && lineBuf.Len() > 0 { + buf.WriteString(lineBuf.String()) + lineBuf.Reset() + buf.WriteString("\n//\n") + continue + } + // Otherwise, fill in the current paragraph. + for field := range strings.FieldsSeq(line) { + if lineBuf.Len() > 0 && lineBuf.Len()+len(" ")+len(field) > wrapAt { + buf.WriteString(lineBuf.String()) + buf.WriteRune('\n') + lineBuf.Reset() + } + if lineBuf.Len() == 0 { + lineBuf.WriteString("//") + } + lineBuf.WriteString(" ") + lineBuf.WriteString(field) + } + } + if lineBuf.Len() > 0 { + buf.WriteString(lineBuf.String()) + } + return strings.TrimRight(buf.String(), "\n") +} + +// exportName returns an exported name for a Go symbol, based on the given name +// in the JSON schema, removing leading underscores and capitalizing. +func exportName(s string) string { + if strings.HasPrefix(s, "_") { + s = s[1:] + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func assert(cond bool, msg string) { + if !cond { + panic(msg) + } +} + +// Helpers below are copied from gopls' moremaps package. + +// sorted returns an iterator over the entries of m in key order. +func sorted[M ~map[K]V, K cmp.Ordered, V any](m M) iter.Seq2[K, V] { + // TODO(adonovan): use maps.Sorted if proposal #68598 is accepted. + return func(yield func(K, V) bool) { + keys := keySlice(m) + slices.Sort(keys) + for _, k := range keys { + if !yield(k, m[k]) { + break + } + } + } +} + +// keySlice returns the keys of the map M, like slices.Collect(maps.Keys(m)). +func keySlice[M ~map[K]V, K comparable, V any](m M) []K { + r := make([]K, 0, len(m)) + for k := range m { + r = append(r, k) + } + return r +} diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go new file mode 100644 index 0000000..08d9515 --- /dev/null +++ b/internal/protocol/protocol.go @@ -0,0 +1,225 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// Code generated by generate.go. DO NOT EDIT. + +package protocol + +import ( + "encoding/json" + + "github.com/modelcontextprotocol/go-sdk/internal/jsonschema" +) + +// Optional annotations for the client. The client can use annotations to inform +// how objects are used or displayed +type Annotations struct { + // Describes who the intended customer of this object or data is. + // + // It can include multiple entries to indicate content useful for multiple + // audiences (e.g., `["user", "assistant"]`). + Audience []Role `json:"audience,omitempty"` + // Describes how important this data is for operating the server. + // + // A value of 1 means "most important," and indicates that the data is + // effectively required, while 0 means "least important," and indicates that the + // data is entirely optional. + Priority float64 `json:"priority,omitempty"` +} + +type CallToolParams struct { + Arguments json.RawMessage `json:"arguments,omitempty"` + Name string `json:"name"` +} + +// The server's response to a tool call. +// +// Any errors that originate from the tool SHOULD be reported inside the result +// object, with `isError` set to true, _not_ as an MCP protocol-level error +// response. Otherwise, the LLM would not be able to see that an error occurred +// and self-correct. +// +// However, any errors in _finding_ the tool, an error indicating that the +// server does not support tool calls, or any other exceptional conditions, +// should be reported as an MCP error response. +type CallToolResult struct { + // This result property is reserved by the protocol to allow clients and servers + // to attach additional metadata to their responses. + Meta json.RawMessage `json:"_meta,omitempty"` + Content []json.RawMessage `json:"content"` + // Whether the tool call ended in an error. + // + // If not set, this is assumed to be false (the call was successful). + IsError bool `json:"isError,omitempty"` +} + +// Capabilities a client may support. Known capabilities are defined here, in +// this schema, but this is not a closed set: any client can define its own, +// additional capabilities. +type ClientCapabilities struct { + // Experimental, non-standard capabilities that the client supports. + Experimental json.RawMessage `json:"experimental,omitempty"` + // Present if the client supports listing roots. + Roots *struct { + // Whether the client supports notifications for changes to the roots list. + ListChanged bool `json:"listChanged,omitempty"` + } `json:"roots,omitempty"` + // Present if the client supports sampling from an LLM. + Sampling json.RawMessage `json:"sampling,omitempty"` +} + +// Describes the name and version of an MCP implementation. +type Implementation struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type InitializeParams struct { + Capabilities ClientCapabilities `json:"capabilities"` + ClientInfo Implementation `json:"clientInfo"` + // The latest version of the Model Context Protocol that the client supports. + // The client MAY decide to support older versions as well. + ProtocolVersion string `json:"protocolVersion"` +} + +// After receiving an initialize request from the client, the server sends this +// response. +type InitializeResult struct { + // This result property is reserved by the protocol to allow clients and servers + // to attach additional metadata to their responses. + Meta json.RawMessage `json:"_meta,omitempty"` + Capabilities ServerCapabilities `json:"capabilities"` + // Instructions describing how to use the server and its features. + // + // This can be used by clients to improve the LLM's understanding of available + // tools, resources, etc. It can be thought of like a "hint" to the model. For + // example, this information MAY be added to the system prompt. + Instructions string `json:"instructions,omitempty"` + // The version of the Model Context Protocol that the server wants to use. This + // may not match the version that the client requested. If the client cannot + // support this version, it MUST disconnect. + ProtocolVersion string `json:"protocolVersion"` + ServerInfo Implementation `json:"serverInfo"` +} + +type InitializedParams json.RawMessage + +type ListToolsParams struct { + // An opaque token representing the current pagination position. If provided, + // the server should return results starting after this cursor. + Cursor string `json:"cursor,omitempty"` +} + +// The server's response to a tools/list request from the client. +type ListToolsResult struct { + // This result property is reserved by the protocol to allow clients and servers + // to attach additional metadata to their responses. + Meta json.RawMessage `json:"_meta,omitempty"` + // An opaque token representing the pagination position after the last returned + // result. If present, there may be more results available. + NextCursor string `json:"nextCursor,omitempty"` + Tools []Tool `json:"tools"` +} + +// Present if the server offers any prompt templates. +type PromptCapabilities struct { + // Whether this server supports notifications for changes to the prompt list. + ListChanged bool `json:"listChanged,omitempty"` +} + +// Present if the server offers any resources to read. +type ResourceCapabilities struct { + // Whether this server supports notifications for changes to the resource list. + ListChanged bool `json:"listChanged,omitempty"` + // Whether this server supports subscribing to resource updates. + Subscribe bool `json:"subscribe,omitempty"` +} + +// The sender or recipient of messages and data in a conversation. +type Role string + +// Capabilities that a server may support. Known capabilities are defined here, +// in this schema, but this is not a closed set: any server can define its own, +// additional capabilities. +type ServerCapabilities struct { + // Present if the server supports argument autocompletion suggestions. + Completions json.RawMessage `json:"completions,omitempty"` + // Experimental, non-standard capabilities that the server supports. + Experimental json.RawMessage `json:"experimental,omitempty"` + // Present if the server supports sending log messages to the client. + Logging json.RawMessage `json:"logging,omitempty"` + // Present if the server offers any prompt templates. + Prompts *PromptCapabilities `json:"prompts,omitempty"` + // Present if the server offers any resources to read. + Resources *ResourceCapabilities `json:"resources,omitempty"` + // Present if the server offers any tools to call. + Tools *ToolCapabilities `json:"tools,omitempty"` +} + +// Text provided to or from an LLM. +type TextContent struct { + // Optional annotations for the client. + Annotations *Annotations `json:"annotations,omitempty"` + // The text content of the message. + Text string `json:"text"` + Type string `json:"type"` +} + +// Definition for a tool the client can call. +type Tool struct { + // Optional additional tool information. + Annotations *ToolAnnotations `json:"annotations,omitempty"` + // A human-readable description of the tool. + // + // This can be used by clients to improve the LLM's understanding of available + // tools. It can be thought of like a "hint" to the model. + Description string `json:"description,omitempty"` + // A JSON Schema object defining the expected parameters for the tool. + InputSchema *jsonschema.Schema `json:"inputSchema"` + // The name of the tool. + Name string `json:"name"` +} + +// Additional properties describing a Tool to clients. +// +// NOTE: all properties in ToolAnnotations are **hints**. They are not +// guaranteed to provide a faithful description of tool behavior (including +// descriptive properties like `title`). +// +// Clients should never make tool use decisions based on ToolAnnotations +// received from untrusted servers. +type ToolAnnotations struct { + // If true, the tool may perform destructive updates to its environment. If + // false, the tool performs only additive updates. + // + // (This property is meaningful only when `readOnlyHint == false`) + // + // Default: true + DestructiveHint bool `json:"destructiveHint,omitempty"` + // If true, calling the tool repeatedly with the same arguments will have no + // additional effect on the its environment. + // + // (This property is meaningful only when `readOnlyHint == false`) + // + // Default: false + IdempotentHint bool `json:"idempotentHint,omitempty"` + // If true, this tool may interact with an "open world" of external entities. If + // false, the tool's domain of interaction is closed. For example, the world of + // a web search tool is open, whereas that of a memory tool is not. + // + // Default: true + OpenWorldHint bool `json:"openWorldHint,omitempty"` + // If true, the tool does not modify its environment. + // + // Default: false + ReadOnlyHint bool `json:"readOnlyHint,omitempty"` + // A human-readable title for the tool. + Title string `json:"title,omitempty"` +} + +// Present if the server offers any tools to call. +type ToolCapabilities struct { + // Whether this server supports notifications for changes to the tool list. + ListChanged bool `json:"listChanged,omitempty"` +} diff --git a/mcp/client.go b/mcp/client.go new file mode 100644 index 0000000..9e6ee66 --- /dev/null +++ b/mcp/client.go @@ -0,0 +1,191 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "iter" + "slices" + "sync" + + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/internal/protocol" +) + +// A Client is an MCP client, which may be connected to one or more MCP servers +// using the [Client.Connect] method. +// +// TODO(rfindley): revisit the many-to-one relationship of clients and servers. +// It is a bit odd. +type Client struct { + name string + version string + + mu sync.Mutex + servers []*ServerConnection +} + +// NewClient creates a new Client. +// +// Use [Client.Connect] to connect it to an MCP server. +// +// If non-nil, the provided options configure the Client. +func NewClient(name, version string, opts *ClientOptions) *Client { + return &Client{ + name: name, + version: version, + } +} + +// Servers returns an iterator that yields the current set of server +// connections. +func (c *Client) Servers() iter.Seq[*ServerConnection] { + c.mu.Lock() + clients := slices.Clone(c.servers) + c.mu.Unlock() + return slices.Values(clients) +} + +// ClientOptions configures the behavior of the client, and apply to every +// client-server connection created using [Client.Connect]. +type ClientOptions struct{} + +// bind implements the binder[*ServerConnection] interface, so that Clients can +// be connected using [connect]. +func (c *Client) bind(conn *jsonrpc2.Connection) *ServerConnection { + sc := &ServerConnection{ + conn: conn, + client: c, + } + c.mu.Lock() + c.servers = append(c.servers, sc) + c.mu.Unlock() + return sc +} + +// disconnect implements the binder[*ServerConnection] interface, so that +// Clients can be connected using [connect]. +func (c *Client) disconnect(sc *ServerConnection) { + c.mu.Lock() + defer c.mu.Unlock() + c.servers = slices.DeleteFunc(c.servers, func(sc2 *ServerConnection) bool { + return sc2 == sc + }) +} + +// Connect connects the MCP client over the given transport and initializes an +// MCP session. +// +// It returns a connection object that may be used to query the MCP server, +// terminate the connection (with [Connection.Close]), or await server +// termination (with [Connection.Wait]). +// +// Typically, it is the responsibility of the client to close the connection +// when it is no longer needed. However, if the connection is closed by the +// server, calls or notifications will return an error wrapping +// [ErrConnectionClosed]. +func (c *Client) Connect(ctx context.Context, t *Transport, opts *ConnectionOptions) (sc *ServerConnection, err error) { + defer func() { + if sc != nil && err != nil { + _ = sc.Close() + } + }() + sc, err = connect(ctx, t, opts, c) + if err != nil { + return nil, err + } + params := &protocol.InitializeParams{ + ClientInfo: protocol.Implementation{Name: c.name, Version: c.version}, + } + if err := call(ctx, sc.conn, "initialize", params, &sc.initializeResult); err != nil { + return nil, err + } + if err := sc.conn.Notify(ctx, "initialized", &protocol.InitializedParams{}); err != nil { + return nil, err + } + return sc, nil +} + +// A ServerConnection is a connection with an MCP server. +// +// It handles messages from the client, and can be used to send messages to the +// client. Create a connection by calling [Server.Connect]. +type ServerConnection struct { + conn *jsonrpc2.Connection + client *Client + initializeResult *protocol.InitializeResult +} + +// Close performs a graceful close of the connection, preventing new requests +// from being handled, and waiting for ongoing requests to return. Close then +// terminates the connection. +func (cc *ServerConnection) Close() error { + return cc.conn.Close() +} + +// Wait waits for the connection to be closed by the server. +// Generally, clients should be responsible for closing the connection. +func (cc *ServerConnection) Wait() error { + return cc.conn.Wait() +} + +func (sc *ServerConnection) handle(ctx context.Context, req *jsonrpc2.Request) (any, error) { + switch req.Method { + } + return nil, jsonrpc2.ErrNotHandled +} + +// ListTools lists tools that are currently available on the server. +func (sc *ServerConnection) ListTools(ctx context.Context) ([]protocol.Tool, error) { + var ( + params = &protocol.ListToolsParams{} + result protocol.ListToolsResult + ) + if err := call(ctx, sc.conn, "tools/list", params, &result); err != nil { + return nil, err + } + return result.Tools, nil +} + +// CallTool calls the tool with the given name and arguments. +// +// TODO: make the following true: +// If the provided arguments do not conform to the schema for the given tool, +// the call fails. +func (sc *ServerConnection) CallTool(ctx context.Context, name string, args any) (_ []Content, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("calling tool %q: %w", name, err) + } + }() + argJSON, err := json.Marshal(args) + if err != nil { + return nil, fmt.Errorf("marshaling args: %v", err) + } + var ( + params = &protocol.CallToolParams{ + Name: name, + Arguments: argJSON, + } + result protocol.CallToolResult + ) + if err := call(ctx, sc.conn, "tools/call", params, &result); err != nil { + return nil, err + } + content, err := unmarshalContent(result.Content) + if err != nil { + return nil, fmt.Errorf("unmarshaling tool content: %v", err) + } + if result.IsError { + if len(content) != 1 || !is[TextContent](content[0]) { + return nil, errors.New("malformed error content") + } + return nil, errors.New(content[0].(TextContent).Text) + } + return content, nil +} diff --git a/mcp/content.go b/mcp/content.go new file mode 100644 index 0000000..987ee0e --- /dev/null +++ b/mcp/content.go @@ -0,0 +1,64 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "encoding/json" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/internal/protocol" +) + +// Content is the abstract result of a Tool call. +// +// TODO: support all content types. +type Content interface { + toProtocol() any +} + +func marshalContent(content []Content) []json.RawMessage { + var msgs []json.RawMessage + for _, c := range content { + msg, err := json.Marshal(c.toProtocol()) + if err != nil { + panic(fmt.Sprintf("marshaling content: %v", err)) + } + msgs = append(msgs, msg) + } + return msgs +} + +func unmarshalContent(msgs []json.RawMessage) ([]Content, error) { + var content []Content + for _, msg := range msgs { + var allContent struct { + Type string `json:"type"` + Text json.RawMessage + } + if err := json.Unmarshal(msg, &allContent); err != nil { + return nil, fmt.Errorf("content missing \"type\"") + } + switch allContent.Type { + case "text": + var text string + if err := json.Unmarshal(allContent.Text, &text); err != nil { + return nil, fmt.Errorf("unmarshalling text content: %v", err) + } + content = append(content, TextContent{Text: text}) + default: + return nil, fmt.Errorf("unsupported content type %q", allContent.Type) + } + } + return content, nil +} + +// TextContent is a textual content. +type TextContent struct { + Text string +} + +func (c TextContent) toProtocol() any { + return protocol.TextContent{Type: "text", Text: c.Text} +} diff --git a/mcp/mcp.go b/mcp/mcp.go new file mode 100644 index 0000000..61c988e --- /dev/null +++ b/mcp/mcp.go @@ -0,0 +1,23 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// The mcp package provides an SDK for writing model context protocol clients +// and servers. It is a work-in-progress. As of writing, it is a prototype to +// explore the design space of client/server lifecycle and binding. +// +// To get started, create an MCP client or server with [NewClient] or +// [NewServer], then add features to your client or server using Add +// methods, then connect to a peer using a [Transport] instance and a call to +// [Client.Connect] or [Server.Connect]. +// +// TODO: +// - Support cancellation. +// - Support pagination. +// - Support all client/server operations. +// - Support Streamable HTTP transport. +// - Support multiple versions of the spec. +// - Implement proper JSON schema support, with both client-side and +// server-side validation.. +// - Support batched JSON messages. +package mcp diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go new file mode 100644 index 0000000..f968cc4 --- /dev/null +++ b/mcp/mcp_test.go @@ -0,0 +1,161 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp_test + +import ( + "context" + "errors" + "slices" + "strings" + "sync" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/internal/jsonschema" + "github.com/modelcontextprotocol/go-sdk/internal/protocol" +) + +type hiParams struct { + Name string +} + +func sayHi(_ context.Context, v hiParams) ([]mcp.Content, error) { + return []mcp.Content{mcp.TextContent{Text: "hi " + v.Name}}, nil +} + +func TestEndToEnd(t *testing.T) { + ctx := context.Background() + ct, st := mcp.NewLocalTransport() + + s := mcp.NewServer("testServer", "v1.0.0", nil) + + // The 'greet' tool says hi. + s.AddTools(mcp.MakeTool("greet", "say hi", sayHi)) + + // The 'fail' tool returns this error. + failure := errors.New("mcp failure") + s.AddTools(mcp.MakeTool("fail", "just fail", func(context.Context, struct{}) ([]mcp.Content, error) { + return nil, failure + })) + + // Connect the server. + cc, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + if got := slices.Collect(s.Clients()); len(got) != 1 { + t.Errorf("after connection, Clients() has length %d, want 1", len(got)) + } + + // Wait for the server to exit after the client closes its connection. + var clientWG sync.WaitGroup + clientWG.Add(1) + go func() { + if err := cc.Wait(); err != nil { + t.Errorf("server failed: %v", err) + } + clientWG.Done() + }() + + c := mcp.NewClient("testClient", "v1.0.0", nil) + + // Connect the client. + sc, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + if got := slices.Collect(c.Servers()); len(got) != 1 { + t.Errorf("after connection, Servers() has length %d, want 1", len(got)) + } + + gotTools, err := sc.ListTools(ctx) + if err != nil { + t.Errorf("tools/list failed: %v", err) + } + wantTools := []protocol.Tool{{ + Name: "greet", + Description: "say hi", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "Name": {Type: "string"}, + }, + AdditionalProperties: false, + }, + }, { + Name: "fail", + Description: "just fail", + InputSchema: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: false, + }, + }} + if diff := cmp.Diff(wantTools, gotTools); diff != "" { + t.Fatalf("tools/list mismatch (-want +got):\n%s", diff) + } + + gotHi, err := sc.CallTool(ctx, "greet", hiParams{"user"}) + if err != nil { + t.Fatal(err) + } + wantHi := []mcp.Content{mcp.TextContent{Text: "hi user"}} + if diff := cmp.Diff(wantHi, gotHi); diff != "" { + t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff) + } + + if _, err := sc.CallTool(ctx, "fail", struct{}{}); err == nil || !strings.Contains(err.Error(), failure.Error()) { + t.Errorf("fail returned unexpected error: got %v, want containing %v", err, failure) + } + + // Disconnect. + sc.Close() + clientWG.Wait() + + // After disconnecting, neither client nor server should have any + // connections. + for range s.Clients() { + t.Errorf("unexpected client after disconnection") + } + for range c.Servers() { + t.Errorf("unexpected server after disconnection") + } +} + +func TestServerClosing(t *testing.T) { + ctx := context.Background() + ct, st := mcp.NewLocalTransport() + + s := mcp.NewServer("testServer", "v1.0.0", nil) + + // The 'greet' tool says hi. + s.AddTools(mcp.MakeTool("greet", "say hi", sayHi)) + cc, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + + c := mcp.NewClient("testClient", "v1.0.0", nil) + sc, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + wg.Add(1) + go func() { + if err := sc.Wait(); err != nil { + t.Errorf("server connection failed: %v", err) + } + wg.Done() + }() + if _, err := sc.CallTool(ctx, "greet", hiParams{"user"}); err != nil { + t.Fatalf("after connecting: %v", err) + } + cc.Close() + wg.Wait() + if _, err = sc.CallTool(ctx, "greet", hiParams{"user"}); !errors.Is(err, mcp.ErrConnectionClosed) { + t.Errorf("after disconnection, got error %v, want EOF", err) + } +} diff --git a/mcp/server.go b/mcp/server.go new file mode 100644 index 0000000..81fb06b --- /dev/null +++ b/mcp/server.go @@ -0,0 +1,212 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "iter" + "slices" + "sync" + + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/internal/protocol" +) + +// A Server is an instance of an MCP server. +// +// Servers expose server-side MCP features, which can serve one or more MCP +// sessions by using [Server.Start] or [Server.Run]. +type Server struct { + name string + version string + opts ServerOptions + + mu sync.Mutex + tools []*Tool + clients []*ClientConnection +} + +// ServerOptions is used to configure behavior of the server. +type ServerOptions struct { + Instructions string +} + +// NewServer creates a new MCP server. The resulting server has no features: +// add features using [Server.AddTools]. (TODO: support more features). +// +// The server can be connected to one or more MCP clients using [Server.Start] +// or [Server.Run]. +// +// If non-nil, the provided options is used to configure the server. +func NewServer(name, version string, opts *ServerOptions) *Server { + if opts == nil { + opts = new(ServerOptions) + } + return &Server{ + name: name, + version: version, + opts: *opts, + } +} + +// AddTools adds the given tools to the server. +// +// TODO(rfindley): notify connected clients of any changes. +func (c *Server) AddTools(tools ...*Tool) { + c.mu.Lock() + defer c.mu.Unlock() + c.tools = append(c.tools, tools...) +} + +// Clients returns an iterator that yields the current set of client +// connections. +func (s *Server) Clients() iter.Seq[*ClientConnection] { + s.mu.Lock() + clients := slices.Clone(s.clients) + s.mu.Unlock() + return slices.Values(clients) +} + +func (c *Server) listTools(_ context.Context, params *protocol.ListToolsParams) (*protocol.ListToolsResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + + res := new(protocol.ListToolsResult) + for _, t := range c.tools { + res.Tools = append(res.Tools, t.Definition) + } + return res, nil +} + +func (c *Server) callTool(ctx context.Context, params *protocol.CallToolParams) (*protocol.CallToolResult, error) { + c.mu.Lock() + var tool *Tool + if i := slices.IndexFunc(c.tools, func(t *Tool) bool { + return t.Definition.Name == params.Name + }); i >= 0 { + tool = c.tools[i] + } + c.mu.Unlock() + + if tool == nil { + return nil, fmt.Errorf("%s: unknown tool %q", jsonrpc2.ErrInvalidParams, params.Name) + } + return tool.Handler(ctx, params.Arguments) +} + +// Run runs the server over the given transport. +// +// Run blocks until the client terminates the connection. +func (c *Server) Run(ctx context.Context, t *Transport, opts *ConnectionOptions) error { + conn, err := c.Connect(ctx, t, opts) + if err != nil { + return err + } + return conn.Wait() +} + +// bind implements the binder[*ClientConnection] interface, so that Servers can +// be connected using [connect]. +func (c *Server) bind(conn *jsonrpc2.Connection) *ClientConnection { + cc := &ClientConnection{conn: conn, server: c} + c.mu.Lock() + c.clients = append(c.clients, cc) + c.mu.Unlock() + return cc +} + +// disconnect implements the binder[*ClientConnection] interface, so that +// Servers can be connected using [connect]. +func (c *Server) disconnect(cc *ClientConnection) { + c.mu.Lock() + defer c.mu.Unlock() + c.clients = slices.DeleteFunc(c.clients, func(cc2 *ClientConnection) bool { + return cc2 == cc + }) +} + +// Connect connects the MCP server over the given transport and starts handling +// messages. +// +// It returns a connection object that may be used to terminate the connection +// (with [Connection.Close]), or await client termination (with +// [Connection.Wait]). +func (c *Server) Connect(ctx context.Context, t *Transport, opts *ConnectionOptions) (*ClientConnection, error) { + return connect(ctx, t, opts, c) +} + +// A ClientConnection is a connection with an MCP client. +// +// It handles messages from the client, and can be used to send messages to the +// client. Create a connection by calling [Server.Connect]. +type ClientConnection struct { + conn *jsonrpc2.Connection + server *Server + + mu sync.Mutex + initializeParams *protocol.InitializeParams // set once initialize has been received +} + +func (cc *ClientConnection) handle(ctx context.Context, req *jsonrpc2.Request) (any, error) { + switch req.Method { + case "initialize": + return dispatch(ctx, req, cc.initialize) + + // TODO: handle initialized + + case "tools/list": + return dispatch(ctx, req, cc.server.listTools) + + case "tools/call": + return dispatch(ctx, req, cc.server.callTool) + + case "notifications/initialized": + } + return nil, jsonrpc2.ErrNotHandled +} + +func (cc *ClientConnection) initialize(ctx context.Context, params *protocol.InitializeParams) (*protocol.InitializeResult, error) { + cc.mu.Lock() + cc.initializeParams = params + cc.mu.Unlock() + + return &protocol.InitializeResult{ + // TODO(rfindley): support multiple protocol versions. + ProtocolVersion: "2024-11-05", + Capabilities: protocol.ServerCapabilities{ + Tools: &protocol.ToolCapabilities{ + ListChanged: true, + }, + }, + Instructions: cc.server.opts.Instructions, + ServerInfo: protocol.Implementation{ + Name: cc.server.name, + Version: cc.server.version, + }, + }, nil +} + +// Close performs a graceful close of the connection, preventing new requests +// from being handled, and waiting for ongoing requests to return. Close then +// terminates the connection. +func (cc *ClientConnection) Close() error { + return cc.conn.Close() +} + +// Wait waits for the connection to be closed by the client. +func (cc *ClientConnection) Wait() error { + return cc.conn.Wait() +} + +func dispatch[TParams, TResult any](ctx context.Context, req *jsonrpc2.Request, f func(context.Context, TParams) (TResult, error)) (TResult, error) { + var params TParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + var zero TResult + return zero, err + } + return f(ctx, params) +} diff --git a/mcp/tool.go b/mcp/tool.go new file mode 100644 index 0000000..0293326 --- /dev/null +++ b/mcp/tool.go @@ -0,0 +1,70 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "encoding/json" + + "github.com/modelcontextprotocol/go-sdk/internal/jsonschema" + "github.com/modelcontextprotocol/go-sdk/internal/protocol" +) + +// A ToolHandler handles a call to tools/call. +type ToolHandler func(context.Context, json.RawMessage) (*protocol.CallToolResult, error) + +// A Tool is a tool definition that is bound to a tool handler. +type Tool struct { + Definition protocol.Tool + Handler ToolHandler +} + +// MakeTool is a helper to make a tool using reflection on the given handler. +// +// The input schema for the tool is extracted from the request type for the +// handler, and used to unmmarshal and validate requests to the handler. +// +// It is the caller's responsibility that the handler request type can produce +// a valid schema, as documented by [jsonschema.ForType]; otherwise, MakeTool +// panics. +func MakeTool[TReq any](name, description string, handler func(context.Context, TReq) ([]Content, error)) *Tool { + schema, err := jsonschema.ForType[TReq]() + if err != nil { + panic(err) + } + wrapped := func(ctx context.Context, args json.RawMessage) (*protocol.CallToolResult, error) { + var v TReq + if err := unmarshalSchema(args, schema, &v); err != nil { + return nil, err + } + content, err := handler(ctx, v) + if err != nil { + return &protocol.CallToolResult{ + Content: marshalContent([]Content{TextContent{Text: err.Error()}}), + IsError: true, + }, nil + } + res := &protocol.CallToolResult{ + Content: marshalContent(content), + } + return res, nil + } + return &Tool{ + Definition: protocol.Tool{ + Name: name, + Description: description, + InputSchema: schema, + }, + Handler: wrapped, + } +} + +// unmarshalSchema unmarshals data into v and validates the result according to +// the given schema. +func unmarshalSchema(data json.RawMessage, _ *jsonschema.Schema, v any) error { + // TODO: use reflection to create the struct type to unmarshal into. + // Separate validation from assignment. + return json.Unmarshal(data, v) +} diff --git a/mcp/transport.go b/mcp/transport.go new file mode 100644 index 0000000..e82a325 --- /dev/null +++ b/mcp/transport.go @@ -0,0 +1,259 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "os" + + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" +) + +// A JSONRPC2 error is an error defined by the JSONRPC2 spec. +type JSONRPC2Error = jsonrpc2.WireError + +// ErrConnectionClosed is returned when sending a message to a connection that +// is closed or in the process of closing. +var ErrConnectionClosed = errors.New("connection closed") + +// A Transport is used to create a bidirectional connection between MCP client +// and server. +type Transport struct { + dialer jsonrpc2.Dialer +} + +// ConnectionOptions configures the behavior of an individual client<->server +// connection. +type ConnectionOptions struct { + Logger io.Writer +} + +// NewStdIOTransport constructs a transport that communicates over +// stdin/stdout. +func NewStdIOTransport() *Transport { + dialer := dialerFunc(func(ctx context.Context) (io.ReadWriteCloser, error) { + return rwc{os.Stdin, os.Stdout}, nil + }) + return &Transport{ + dialer: dialer, + } +} + +// NewLocalTransport returns two in-memory transports that connect to +// each other, for testing purposes. +func NewLocalTransport() (*Transport, *Transport) { + c1, c2 := net.Pipe() + t1 := &Transport{ + dialer: dialerFunc(func(ctx context.Context) (io.ReadWriteCloser, error) { + return c1, nil + }), + } + t2 := &Transport{ + dialer: dialerFunc(func(ctx context.Context) (io.ReadWriteCloser, error) { + return c2, nil + }), + } + return t1, t2 +} + +// handler is an unexported version of jsonrpc2.Handler, to be implemented by +// [ServerConnection] and [ClientConnection]. +type handler interface { + handle(ctx context.Context, req *jsonrpc2.Request) (result any, err error) + comparable +} + +type binder[T handler] interface { + bind(*jsonrpc2.Connection) T + disconnect(T) +} + +func connect[H handler](ctx context.Context, t *Transport, opts *ConnectionOptions, b binder[H]) (H, error) { + if opts == nil { + opts = new(ConnectionOptions) + } + + // Frame messages using newline delimited JSON. + // + // If logging is configured, write message logs. + var framer jsonrpc2.Framer = &ndjsonFramer{} + if opts.Logger != nil { + framer = &loggingFramer{opts.Logger, framer} + } + + var h H + + // Bind the server connection. + binder := jsonrpc2.BinderFunc(func(_ context.Context, conn *jsonrpc2.Connection) jsonrpc2.ConnectionOptions { + h = b.bind(conn) + return jsonrpc2.ConnectionOptions{ + Framer: framer, + Handler: jsonrpc2.HandlerFunc(h.handle), + OnInternalError: func(err error) { + log.Printf("Internal error: %v", err) + }, + } + }) + + // Clean up the connection when done. + onDone := func() { + b.disconnect(h) + } + + var zero H + _, err := jsonrpc2.Dial(ctx, t.dialer, binder, onDone) + if err != nil { + return zero, err + } + assert(h != zero, "unbound connection") + return h, nil +} + +// call executes and awaits a jsonrpc2 call on the given connection, +// translating errors into the mcp domain. +func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params, result any) error { + err := conn.Call(ctx, method, params).Await(ctx, result) + switch { + case errors.Is(err, jsonrpc2.ErrClientClosing), errors.Is(err, jsonrpc2.ErrServerClosing): + return fmt.Errorf("calling %q: %w", method, ErrConnectionClosed) + case err != nil: + return fmt.Errorf("calling %q: %v", method, err) + } + return nil +} + +// The helpers below are used to bind transports to jsonrpc2. + +// A dialerFunc implements jsonrpc2.Dialer.Dial. +type dialerFunc func(context.Context) (io.ReadWriteCloser, error) + +func (f dialerFunc) Dial(ctx context.Context) (io.ReadWriteCloser, error) { + return f(ctx) +} + +// A readerFunc implements jsonrpc2.Reader.Read. +type readerFunc func(context.Context) (jsonrpc2.Message, int64, error) + +func (f readerFunc) Read(ctx context.Context) (jsonrpc2.Message, int64, error) { + return f(ctx) +} + +// A writerFunc implements jsonrpc2.Writer.Write. +type writerFunc func(context.Context, jsonrpc2.Message) (int64, error) + +func (f writerFunc) Write(ctx context.Context, msg jsonrpc2.Message) (int64, error) { + return f(ctx, msg) +} + +// A loggingFramer logs jsonrpc2 messages to its enclosed writer. +type loggingFramer struct { + w io.Writer + delegate jsonrpc2.Framer +} + +func (f *loggingFramer) Reader(rw io.Reader) jsonrpc2.Reader { + delegate := f.delegate.Reader(rw) + return readerFunc(func(ctx context.Context) (jsonrpc2.Message, int64, error) { + msg, n, err := delegate.Read(ctx) + if err != nil { + fmt.Fprintf(f.w, "read error: %v", err) + } else { + data, err := jsonrpc2.EncodeMessage(msg) + if err != nil { + fmt.Fprintf(f.w, "LoggingFramer: failed to marshal: %v", err) + } + fmt.Fprintf(f.w, "read: %s", string(data)) + } + return msg, n, err + }) +} + +func (f *loggingFramer) Writer(w io.Writer) jsonrpc2.Writer { + delegate := f.delegate.Writer(w) + return writerFunc(func(ctx context.Context, msg jsonrpc2.Message) (int64, error) { + n, err := delegate.Write(ctx, msg) + if err != nil { + fmt.Fprintf(f.w, "write error: %v", err) + } else { + data, err := jsonrpc2.EncodeMessage(msg) + if err != nil { + fmt.Fprintf(f.w, "LoggingFramer: failed to marshal: %v", err) + } + fmt.Fprintf(f.w, "write: %s", string(data)) + } + return n, err + }) +} + +// A rwc binds an io.ReadCloser and io.WriteCloser together to create an +// io.ReadWriteCloser. +type rwc struct { + rc io.ReadCloser + wc io.WriteCloser +} + +func (r rwc) Read(p []byte) (n int, err error) { + n, err = r.rc.Read(p) + return n, err +} + +func (r rwc) Write(p []byte) (n int, err error) { + n, err = r.wc.Write(p) + return n, err +} + +func (r rwc) Close() error { + return errors.Join(r.rc.Close(), r.wc.Close()) +} + +// A ndjsonFramer is a jsonrpc2.Framer that delimits messages with newlines. +// +// See also https://github.com/ndjson/ndjson-spec. +type ndjsonFramer struct{} +type rawReader struct{ in *json.Decoder } // relies on json.Decoder message boundaries +type ndjsonWriter struct{ out io.Writer } // writes newline message boundaries + +func (ndjsonFramer) Reader(rw io.Reader) jsonrpc2.Reader { + return &rawReader{in: json.NewDecoder(rw)} +} + +func (ndjsonFramer) Writer(rw io.Writer) jsonrpc2.Writer { + return &ndjsonWriter{out: rw} +} + +func (r *rawReader) Read(ctx context.Context) (jsonrpc2.Message, int64, error) { + select { + case <-ctx.Done(): + return nil, 0, ctx.Err() + default: + } + var raw json.RawMessage + if err := r.in.Decode(&raw); err != nil { + return nil, 0, err + } + msg, err := jsonrpc2.DecodeMessage(raw) + return msg, int64(len(raw)), err +} + +func (w *ndjsonWriter) Write(ctx context.Context, msg jsonrpc2.Message) (int64, error) { + select { + case <-ctx.Done(): + return 0, ctx.Err() + default: + } + data, err := jsonrpc2.EncodeMessage(msg) + if err != nil { + return 0, fmt.Errorf("marshaling message: %v", err) + } + data = append(data, '\n') // newline delimited + n, err := w.out.Write(data) + return int64(n), err +} diff --git a/mcp/util.go b/mcp/util.go new file mode 100644 index 0000000..14d99dd --- /dev/null +++ b/mcp/util.go @@ -0,0 +1,16 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +func assert(cond bool, msg string) { + if !cond { + panic(msg) + } +} + +func is[T any](v any) bool { + _, ok := v.(T) + return ok +}