fix(go.sum): update ResolveSpec dependency to v1.0.87
This commit is contained in:
+1182
File diff suppressed because it is too large
Load Diff
+108
@@ -0,0 +1,108 @@
|
||||
// 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"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var defaultTerminateDuration = 5 * time.Second // mutable for testing
|
||||
|
||||
// A CommandTransport is a [Transport] that runs a command and communicates
|
||||
// with it over stdin/stdout, using newline-delimited JSON.
|
||||
type CommandTransport struct {
|
||||
Command *exec.Cmd
|
||||
// TerminateDuration controls how long Close waits after closing stdin
|
||||
// for the process to exit before sending SIGTERM.
|
||||
// If zero or negative, the default of 5s is used.
|
||||
TerminateDuration time.Duration
|
||||
}
|
||||
|
||||
// Connect starts the command, and connects to it over stdin/stdout.
|
||||
func (t *CommandTransport) Connect(ctx context.Context) (Connection, error) {
|
||||
stdout, err := t.Command.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdout = io.NopCloser(stdout) // close the connection by closing stdin, not stdout
|
||||
stdin, err := t.Command.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.Command.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
td := t.TerminateDuration
|
||||
if td <= 0 {
|
||||
td = defaultTerminateDuration
|
||||
}
|
||||
return newIOConn(&pipeRWC{t.Command, stdout, stdin, td}), nil
|
||||
}
|
||||
|
||||
// A pipeRWC is an io.ReadWriteCloser that communicates with a subprocess over
|
||||
// stdin/stdout pipes.
|
||||
type pipeRWC struct {
|
||||
cmd *exec.Cmd
|
||||
stdout io.ReadCloser
|
||||
stdin io.WriteCloser
|
||||
terminateDuration time.Duration
|
||||
}
|
||||
|
||||
func (s *pipeRWC) Read(p []byte) (n int, err error) {
|
||||
return s.stdout.Read(p)
|
||||
}
|
||||
|
||||
func (s *pipeRWC) Write(p []byte) (n int, err error) {
|
||||
return s.stdin.Write(p)
|
||||
}
|
||||
|
||||
// Close closes the input stream to the child process, and awaits normal
|
||||
// termination of the command. If the command does not exit, it is signalled to
|
||||
// terminate, and then eventually killed.
|
||||
func (s *pipeRWC) Close() error {
|
||||
// Spec:
|
||||
// "For the stdio transport, the client SHOULD initiate shutdown by:...
|
||||
|
||||
// "...First, closing the input stream to the child process (the server)"
|
||||
if err := s.stdin.Close(); err != nil {
|
||||
return fmt.Errorf("closing stdin: %v", err)
|
||||
}
|
||||
resChan := make(chan error, 1)
|
||||
go func() {
|
||||
resChan <- s.cmd.Wait()
|
||||
}()
|
||||
// "...Waiting for the server to exit, or sending SIGTERM if the server does not exit within a reasonable time"
|
||||
wait := func() (error, bool) {
|
||||
select {
|
||||
case err := <-resChan:
|
||||
return err, true
|
||||
case <-time.After(s.terminateDuration):
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if err, ok := wait(); ok {
|
||||
return err
|
||||
}
|
||||
// Note the condition here: if sending SIGTERM fails, don't wait and just
|
||||
// move on to SIGKILL.
|
||||
if err := s.cmd.Process.Signal(syscall.SIGTERM); err == nil {
|
||||
if err, ok := wait(); ok {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// "...Sending SIGKILL if the server does not exit within a reasonable time after SIGTERM"
|
||||
if err := s.cmd.Process.Kill(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err, ok := wait(); ok {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("unresponsive subprocess")
|
||||
}
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
// 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.
|
||||
|
||||
// TODO(findleyr): update JSON marshalling of all content types to preserve required fields.
|
||||
// (See [TextContent.MarshalJSON], which handles this for text content).
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
)
|
||||
|
||||
// A Content is a [TextContent], [ImageContent], [AudioContent],
|
||||
// [ResourceLink], [EmbeddedResource], [ToolUseContent], or [ToolResultContent].
|
||||
//
|
||||
// Note: [ToolUseContent] and [ToolResultContent] are only valid in sampling
|
||||
// message contexts (CreateMessageParams/CreateMessageResult).
|
||||
type Content interface {
|
||||
MarshalJSON() ([]byte, error)
|
||||
fromWire(*wireContent)
|
||||
}
|
||||
|
||||
// TextContent is a textual content.
|
||||
type TextContent struct {
|
||||
Text string
|
||||
Meta Meta
|
||||
Annotations *Annotations
|
||||
}
|
||||
|
||||
func (c *TextContent) MarshalJSON() ([]byte, error) {
|
||||
// Custom wire format to ensure the required "text" field is always included, even when empty.
|
||||
wire := struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Meta Meta `json:"_meta,omitempty"`
|
||||
Annotations *Annotations `json:"annotations,omitempty"`
|
||||
}{
|
||||
Type: "text",
|
||||
Text: c.Text,
|
||||
Meta: c.Meta,
|
||||
Annotations: c.Annotations,
|
||||
}
|
||||
return json.Marshal(wire)
|
||||
}
|
||||
|
||||
func (c *TextContent) fromWire(wire *wireContent) {
|
||||
c.Text = wire.Text
|
||||
c.Meta = wire.Meta
|
||||
c.Annotations = wire.Annotations
|
||||
}
|
||||
|
||||
// ImageContent contains base64-encoded image data.
|
||||
type ImageContent struct {
|
||||
Meta Meta
|
||||
Annotations *Annotations
|
||||
Data []byte // base64-encoded
|
||||
MIMEType string
|
||||
}
|
||||
|
||||
func (c *ImageContent) MarshalJSON() ([]byte, error) {
|
||||
// Custom wire format to ensure required fields are always included, even when empty.
|
||||
data := c.Data
|
||||
if data == nil {
|
||||
data = []byte{}
|
||||
}
|
||||
wire := imageAudioWire{
|
||||
Type: "image",
|
||||
MIMEType: c.MIMEType,
|
||||
Data: data,
|
||||
Meta: c.Meta,
|
||||
Annotations: c.Annotations,
|
||||
}
|
||||
return json.Marshal(wire)
|
||||
}
|
||||
|
||||
func (c *ImageContent) fromWire(wire *wireContent) {
|
||||
c.MIMEType = wire.MIMEType
|
||||
c.Data = wire.Data
|
||||
c.Meta = wire.Meta
|
||||
c.Annotations = wire.Annotations
|
||||
}
|
||||
|
||||
// AudioContent contains base64-encoded audio data.
|
||||
type AudioContent struct {
|
||||
Data []byte
|
||||
MIMEType string
|
||||
Meta Meta
|
||||
Annotations *Annotations
|
||||
}
|
||||
|
||||
func (c AudioContent) MarshalJSON() ([]byte, error) {
|
||||
// Custom wire format to ensure required fields are always included, even when empty.
|
||||
data := c.Data
|
||||
if data == nil {
|
||||
data = []byte{}
|
||||
}
|
||||
wire := imageAudioWire{
|
||||
Type: "audio",
|
||||
MIMEType: c.MIMEType,
|
||||
Data: data,
|
||||
Meta: c.Meta,
|
||||
Annotations: c.Annotations,
|
||||
}
|
||||
return json.Marshal(wire)
|
||||
}
|
||||
|
||||
func (c *AudioContent) fromWire(wire *wireContent) {
|
||||
c.MIMEType = wire.MIMEType
|
||||
c.Data = wire.Data
|
||||
c.Meta = wire.Meta
|
||||
c.Annotations = wire.Annotations
|
||||
}
|
||||
|
||||
// Custom wire format to ensure required fields are always included, even when empty.
|
||||
type imageAudioWire struct {
|
||||
Type string `json:"type"`
|
||||
MIMEType string `json:"mimeType"`
|
||||
Data []byte `json:"data"`
|
||||
Meta Meta `json:"_meta,omitempty"`
|
||||
Annotations *Annotations `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceLink is a link to a resource
|
||||
type ResourceLink struct {
|
||||
URI string
|
||||
Name string
|
||||
Title string
|
||||
Description string
|
||||
MIMEType string
|
||||
Size *int64
|
||||
Meta Meta
|
||||
Annotations *Annotations
|
||||
// Icons for the resource link, if any.
|
||||
Icons []Icon `json:"icons,omitempty"`
|
||||
}
|
||||
|
||||
func (c *ResourceLink) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(&wireContent{
|
||||
Type: "resource_link",
|
||||
URI: c.URI,
|
||||
Name: c.Name,
|
||||
Title: c.Title,
|
||||
Description: c.Description,
|
||||
MIMEType: c.MIMEType,
|
||||
Size: c.Size,
|
||||
Meta: c.Meta,
|
||||
Annotations: c.Annotations,
|
||||
Icons: c.Icons,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *ResourceLink) fromWire(wire *wireContent) {
|
||||
c.URI = wire.URI
|
||||
c.Name = wire.Name
|
||||
c.Title = wire.Title
|
||||
c.Description = wire.Description
|
||||
c.MIMEType = wire.MIMEType
|
||||
c.Size = wire.Size
|
||||
c.Meta = wire.Meta
|
||||
c.Annotations = wire.Annotations
|
||||
c.Icons = wire.Icons
|
||||
}
|
||||
|
||||
// EmbeddedResource contains embedded resources.
|
||||
type EmbeddedResource struct {
|
||||
Resource *ResourceContents
|
||||
Meta Meta
|
||||
Annotations *Annotations
|
||||
}
|
||||
|
||||
func (c *EmbeddedResource) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(&wireContent{
|
||||
Type: "resource",
|
||||
Resource: c.Resource,
|
||||
Meta: c.Meta,
|
||||
Annotations: c.Annotations,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *EmbeddedResource) fromWire(wire *wireContent) {
|
||||
c.Resource = wire.Resource
|
||||
c.Meta = wire.Meta
|
||||
c.Annotations = wire.Annotations
|
||||
}
|
||||
|
||||
// ToolUseContent represents a request from the assistant to invoke a tool.
|
||||
// This content type is only valid in sampling messages.
|
||||
type ToolUseContent struct {
|
||||
// ID is a unique identifier for this tool use, used to match with ToolResultContent.
|
||||
ID string
|
||||
// Name is the name of the tool to invoke.
|
||||
Name string
|
||||
// Input contains the tool arguments as a JSON object.
|
||||
Input map[string]any
|
||||
Meta Meta
|
||||
}
|
||||
|
||||
func (c *ToolUseContent) MarshalJSON() ([]byte, error) {
|
||||
input := c.Input
|
||||
if input == nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
wire := struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Input map[string]any `json:"input"`
|
||||
Meta Meta `json:"_meta,omitempty"`
|
||||
}{
|
||||
Type: "tool_use",
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Input: input,
|
||||
Meta: c.Meta,
|
||||
}
|
||||
return json.Marshal(wire)
|
||||
}
|
||||
|
||||
func (c *ToolUseContent) fromWire(wire *wireContent) {
|
||||
c.ID = wire.ID
|
||||
c.Name = wire.Name
|
||||
c.Input = wire.Input
|
||||
c.Meta = wire.Meta
|
||||
}
|
||||
|
||||
// ToolResultContent represents the result of a tool invocation.
|
||||
// This content type is only valid in sampling messages with role "user".
|
||||
type ToolResultContent struct {
|
||||
// ToolUseID references the ID from the corresponding ToolUseContent.
|
||||
ToolUseID string
|
||||
// Content holds the unstructured result of the tool call.
|
||||
Content []Content
|
||||
// StructuredContent holds an optional structured result as a JSON object.
|
||||
StructuredContent any
|
||||
// IsError indicates whether the tool call ended in an error.
|
||||
IsError bool
|
||||
Meta Meta
|
||||
}
|
||||
|
||||
func (c *ToolResultContent) MarshalJSON() ([]byte, error) {
|
||||
// Marshal nested content
|
||||
var contentWire []*wireContent
|
||||
for _, content := range c.Content {
|
||||
data, err := content.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var w wireContent
|
||||
if err := internaljson.Unmarshal(data, &w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contentWire = append(contentWire, &w)
|
||||
}
|
||||
if contentWire == nil {
|
||||
contentWire = []*wireContent{} // avoid JSON null
|
||||
}
|
||||
|
||||
wire := struct {
|
||||
Type string `json:"type"`
|
||||
ToolUseID string `json:"toolUseId"`
|
||||
Content []*wireContent `json:"content"`
|
||||
StructuredContent any `json:"structuredContent,omitempty"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
Meta Meta `json:"_meta,omitempty"`
|
||||
}{
|
||||
Type: "tool_result",
|
||||
ToolUseID: c.ToolUseID,
|
||||
Content: contentWire,
|
||||
StructuredContent: c.StructuredContent,
|
||||
IsError: c.IsError,
|
||||
Meta: c.Meta,
|
||||
}
|
||||
return json.Marshal(wire)
|
||||
}
|
||||
|
||||
func (c *ToolResultContent) fromWire(wire *wireContent) {
|
||||
c.ToolUseID = wire.ToolUseID
|
||||
c.StructuredContent = wire.StructuredContent
|
||||
c.IsError = wire.IsError
|
||||
c.Meta = wire.Meta
|
||||
// Content is handled separately in contentFromWire due to nested content
|
||||
}
|
||||
|
||||
// ResourceContents contains the contents of a specific resource or
|
||||
// sub-resource.
|
||||
type ResourceContents struct {
|
||||
URI string `json:"uri"`
|
||||
MIMEType string `json:"mimeType,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Blob []byte `json:"blob,omitzero"`
|
||||
Meta Meta `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
// wireContent is the wire format for content.
|
||||
// It represents the protocol types TextContent, ImageContent, AudioContent,
|
||||
// ResourceLink, EmbeddedResource, ToolUseContent, and ToolResultContent.
|
||||
// The Type field distinguishes them. In the protocol, each type has a constant
|
||||
// value for the field.
|
||||
type wireContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"` // TextContent
|
||||
MIMEType string `json:"mimeType,omitempty"` // ImageContent, AudioContent, ResourceLink
|
||||
Data []byte `json:"data,omitempty"` // ImageContent, AudioContent
|
||||
Resource *ResourceContents `json:"resource,omitempty"` // EmbeddedResource
|
||||
URI string `json:"uri,omitempty"` // ResourceLink
|
||||
Name string `json:"name,omitempty"` // ResourceLink, ToolUseContent
|
||||
Title string `json:"title,omitempty"` // ResourceLink
|
||||
Description string `json:"description,omitempty"` // ResourceLink
|
||||
Size *int64 `json:"size,omitempty"` // ResourceLink
|
||||
Meta Meta `json:"_meta,omitempty"` // all types
|
||||
Annotations *Annotations `json:"annotations,omitempty"` // all types except ToolUseContent, ToolResultContent
|
||||
Icons []Icon `json:"icons,omitempty"` // ResourceLink
|
||||
ID string `json:"id,omitempty"` // ToolUseContent
|
||||
Input map[string]any `json:"input,omitempty"` // ToolUseContent
|
||||
ToolUseID string `json:"toolUseId,omitempty"` // ToolResultContent
|
||||
NestedContent []*wireContent `json:"content,omitempty"` // ToolResultContent
|
||||
StructuredContent any `json:"structuredContent,omitempty"` // ToolResultContent
|
||||
IsError bool `json:"isError,omitempty"` // ToolResultContent
|
||||
}
|
||||
|
||||
// unmarshalContent unmarshals JSON that is either a single content object or
|
||||
// an array of content objects. A single object is wrapped in a one-element slice.
|
||||
func unmarshalContent(raw json.RawMessage, allow map[string]bool) ([]Content, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, fmt.Errorf("nil content")
|
||||
}
|
||||
// Try array first, then fall back to single object.
|
||||
var wires []*wireContent
|
||||
if err := internaljson.Unmarshal(raw, &wires); err == nil {
|
||||
return contentsFromWire(wires, allow)
|
||||
}
|
||||
var wire wireContent
|
||||
if err := internaljson.Unmarshal(raw, &wire); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := contentFromWire(&wire, allow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []Content{c}, nil
|
||||
}
|
||||
|
||||
func contentsFromWire(wires []*wireContent, allow map[string]bool) ([]Content, error) {
|
||||
blocks := make([]Content, 0, len(wires))
|
||||
for _, wire := range wires {
|
||||
block, err := contentFromWire(wire, allow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func contentFromWire(wire *wireContent, allow map[string]bool) (Content, error) {
|
||||
if wire == nil {
|
||||
return nil, fmt.Errorf("nil content")
|
||||
}
|
||||
if allow != nil && !allow[wire.Type] {
|
||||
return nil, fmt.Errorf("invalid content type %q", wire.Type)
|
||||
}
|
||||
switch wire.Type {
|
||||
case "text":
|
||||
v := new(TextContent)
|
||||
v.fromWire(wire)
|
||||
return v, nil
|
||||
case "image":
|
||||
v := new(ImageContent)
|
||||
v.fromWire(wire)
|
||||
return v, nil
|
||||
case "audio":
|
||||
v := new(AudioContent)
|
||||
v.fromWire(wire)
|
||||
return v, nil
|
||||
case "resource_link":
|
||||
v := new(ResourceLink)
|
||||
v.fromWire(wire)
|
||||
return v, nil
|
||||
case "resource":
|
||||
v := new(EmbeddedResource)
|
||||
v.fromWire(wire)
|
||||
return v, nil
|
||||
case "tool_use":
|
||||
v := new(ToolUseContent)
|
||||
v.fromWire(wire)
|
||||
return v, nil
|
||||
case "tool_result":
|
||||
v := new(ToolResultContent)
|
||||
v.fromWire(wire)
|
||||
// Handle nested content - tool_result content can contain text, image, audio,
|
||||
// resource_link, and resource (same as CallToolResult.content)
|
||||
if wire.NestedContent != nil {
|
||||
toolResultContentAllow := map[string]bool{
|
||||
"text": true, "image": true, "audio": true,
|
||||
"resource_link": true, "resource": true,
|
||||
}
|
||||
nestedContent, err := contentsFromWire(wire.NestedContent, toolResultContentAllow)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tool_result nested content: %w", err)
|
||||
}
|
||||
v.Content = nestedContent
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unrecognized content type %q", wire.Type)
|
||||
}
|
||||
+436
@@ -0,0 +1,436 @@
|
||||
// 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.
|
||||
|
||||
// This file is for SSE events.
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"iter"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// If true, MemoryEventStore will do frequent validation to check invariants, slowing it down.
|
||||
// Enable for debugging.
|
||||
const validateMemoryEventStore = false
|
||||
|
||||
// An Event is a server-sent event.
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#fields.
|
||||
type Event struct {
|
||||
Name string // the "event" field
|
||||
ID string // the "id" field
|
||||
Data []byte // the "data" field
|
||||
Retry string // the "retry" field
|
||||
}
|
||||
|
||||
// Empty reports whether the Event is empty.
|
||||
func (e Event) Empty() bool {
|
||||
return e.Name == "" && e.ID == "" && len(e.Data) == 0 && e.Retry == ""
|
||||
}
|
||||
|
||||
// writeEvent writes the event to w, and flushes.
|
||||
func writeEvent(w io.Writer, evt Event) (int, error) {
|
||||
var b bytes.Buffer
|
||||
if evt.Name != "" {
|
||||
fmt.Fprintf(&b, "event: %s\n", evt.Name)
|
||||
}
|
||||
if evt.ID != "" {
|
||||
fmt.Fprintf(&b, "id: %s\n", evt.ID)
|
||||
}
|
||||
if evt.Retry != "" {
|
||||
fmt.Fprintf(&b, "retry: %s\n", evt.Retry)
|
||||
}
|
||||
fmt.Fprintf(&b, "data: %s\n\n", string(evt.Data))
|
||||
n, err := w.Write(b.Bytes())
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// scanEvents iterates SSE events in the given scanner. The iterated error is
|
||||
// terminal: if encountered, the stream is corrupt or broken and should no
|
||||
// longer be used.
|
||||
//
|
||||
// TODO(rfindley): consider a different API here that makes failure modes more
|
||||
// apparent.
|
||||
func scanEvents(r io.Reader) iter.Seq2[Event, error] {
|
||||
reader := bufio.NewReader(r)
|
||||
|
||||
// TODO: investigate proper behavior when events are out of order, or have
|
||||
// non-standard names.
|
||||
var (
|
||||
eventKey = []byte("event")
|
||||
idKey = []byte("id")
|
||||
dataKey = []byte("data")
|
||||
retryKey = []byte("retry")
|
||||
)
|
||||
|
||||
return func(yield func(Event, error) bool) {
|
||||
// iterate event from the wire.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#examples
|
||||
//
|
||||
// - `key: value` line records.
|
||||
// - Consecutive `data: ...` fields are joined with newlines.
|
||||
// - Unrecognized fields are ignored. Since we only care about 'event', 'id', and
|
||||
// 'data', these are the only three we consider.
|
||||
// - Lines starting with ":" are ignored.
|
||||
// - Records are terminated with two consecutive newlines.
|
||||
var (
|
||||
evt Event
|
||||
dataBuf *bytes.Buffer // if non-nil, preceding field was also data
|
||||
)
|
||||
yieldEvent := func() bool {
|
||||
if dataBuf != nil {
|
||||
evt.Data = dataBuf.Bytes()
|
||||
dataBuf = nil
|
||||
}
|
||||
if evt.Empty() {
|
||||
return true
|
||||
}
|
||||
if !yield(evt, nil) {
|
||||
return false
|
||||
}
|
||||
evt = Event{}
|
||||
return true
|
||||
}
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
yield(Event{}, fmt.Errorf("error reading event: %v", err))
|
||||
return
|
||||
}
|
||||
line = bytes.TrimRight(line, "\r\n")
|
||||
isEOF := errors.Is(err, io.EOF)
|
||||
|
||||
if len(line) == 0 {
|
||||
if !yieldEvent() {
|
||||
return
|
||||
}
|
||||
if isEOF {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
before, after, found := bytes.Cut(line, []byte{':'})
|
||||
if !found {
|
||||
yield(Event{}, fmt.Errorf("%w: malformed line in SSE stream: %q", errMalformedEvent, string(line)))
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case bytes.Equal(before, eventKey):
|
||||
evt.Name = strings.TrimSpace(string(after))
|
||||
case bytes.Equal(before, idKey):
|
||||
evt.ID = strings.TrimSpace(string(after))
|
||||
case bytes.Equal(before, retryKey):
|
||||
evt.Retry = strings.TrimSpace(string(after))
|
||||
case bytes.Equal(before, dataKey):
|
||||
data := bytes.TrimSpace(after)
|
||||
if dataBuf == nil {
|
||||
dataBuf = new(bytes.Buffer)
|
||||
} else {
|
||||
dataBuf.WriteByte('\n')
|
||||
}
|
||||
dataBuf.Write(data)
|
||||
}
|
||||
|
||||
if isEOF {
|
||||
yieldEvent()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An EventStore tracks data for SSE streams.
|
||||
// A single EventStore suffices for all sessions, since session IDs are
|
||||
// globally unique. So one EventStore can be created per process, for
|
||||
// all Servers in the process.
|
||||
// Such a store is able to bound resource usage for the entire process.
|
||||
//
|
||||
// All of an EventStore's methods must be safe for use by multiple goroutines.
|
||||
type EventStore interface {
|
||||
// Open is called when a new stream is created. It may be used to ensure that
|
||||
// the underlying data structure for the stream is initialized, making it
|
||||
// ready to store and replay event streams.
|
||||
Open(_ context.Context, sessionID, streamID string) error
|
||||
|
||||
// Append appends data for an outgoing event to given stream, which is part of the
|
||||
// given session.
|
||||
Append(_ context.Context, sessionID, streamID string, data []byte) error
|
||||
|
||||
// After returns an iterator over the data for the given session and stream, beginning
|
||||
// just after the given index.
|
||||
//
|
||||
// Once the iterator yields a non-nil error, it will stop.
|
||||
// After's iterator must return an error immediately if any data after index was
|
||||
// dropped; it must not return partial results.
|
||||
// The stream must have been opened previously (see [EventStore.Open]).
|
||||
After(_ context.Context, sessionID, streamID string, index int) iter.Seq2[[]byte, error]
|
||||
|
||||
// SessionClosed informs the store that the given session is finished, along
|
||||
// with all of its streams.
|
||||
//
|
||||
// A store cannot rely on this method being called for cleanup. It should institute
|
||||
// additional mechanisms, such as timeouts, to reclaim storage.
|
||||
SessionClosed(_ context.Context, sessionID string) error
|
||||
|
||||
// There is no StreamClosed method. A server doesn't know when a stream is finished, because
|
||||
// the client can always send a GET with a Last-Event-ID referring to the stream.
|
||||
}
|
||||
|
||||
// A dataList is a list of []byte.
|
||||
// The zero dataList is ready to use.
|
||||
type dataList struct {
|
||||
size int // total size of data bytes
|
||||
first int // the stream index of the first element in data
|
||||
data [][]byte
|
||||
}
|
||||
|
||||
func (dl *dataList) appendData(d []byte) {
|
||||
// Empty data consumes memory but doesn't increment size. However, it should
|
||||
// be rare.
|
||||
dl.data = append(dl.data, d)
|
||||
dl.size += len(d)
|
||||
}
|
||||
|
||||
// removeFirst removes the first data item in dl, returning the size of the item.
|
||||
// It panics if dl is empty.
|
||||
func (dl *dataList) removeFirst() int {
|
||||
if len(dl.data) == 0 {
|
||||
panic("empty dataList")
|
||||
}
|
||||
r := len(dl.data[0])
|
||||
dl.size -= r
|
||||
dl.data[0] = nil // help GC
|
||||
dl.data = dl.data[1:]
|
||||
dl.first++
|
||||
return r
|
||||
}
|
||||
|
||||
// A MemoryEventStore is an [EventStore] backed by memory.
|
||||
type MemoryEventStore struct {
|
||||
mu sync.Mutex
|
||||
maxBytes int // max total size of all data
|
||||
nBytes int // current total size of all data
|
||||
store map[string]map[string]*dataList // session ID -> stream ID -> *dataList
|
||||
}
|
||||
|
||||
// MemoryEventStoreOptions are options for a [MemoryEventStore].
|
||||
type MemoryEventStoreOptions struct{}
|
||||
|
||||
// MaxBytes returns the maximum number of bytes that the store will retain before
|
||||
// purging data.
|
||||
func (s *MemoryEventStore) MaxBytes() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.maxBytes
|
||||
}
|
||||
|
||||
// SetMaxBytes sets the maximum number of bytes the store will retain before purging
|
||||
// data. The argument must not be negative. If it is zero, a suitable default will be used.
|
||||
// SetMaxBytes can be called at any time. The size of the store will be adjusted
|
||||
// immediately.
|
||||
func (s *MemoryEventStore) SetMaxBytes(n int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch {
|
||||
case n < 0:
|
||||
panic("negative argument")
|
||||
case n == 0:
|
||||
s.maxBytes = defaultMaxBytes
|
||||
default:
|
||||
s.maxBytes = n
|
||||
}
|
||||
s.purge()
|
||||
}
|
||||
|
||||
const defaultMaxBytes = 10 << 20 // 10 MiB
|
||||
|
||||
// NewMemoryEventStore creates a [MemoryEventStore] with the default value
|
||||
// for MaxBytes.
|
||||
func NewMemoryEventStore(opts *MemoryEventStoreOptions) *MemoryEventStore {
|
||||
return &MemoryEventStore{
|
||||
maxBytes: defaultMaxBytes,
|
||||
store: make(map[string]map[string]*dataList),
|
||||
}
|
||||
}
|
||||
|
||||
// Open implements [EventStore.Open]. It ensures that the underlying data
|
||||
// structures for the given session are initialized and ready for use.
|
||||
func (s *MemoryEventStore) Open(_ context.Context, sessionID, streamID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.init(sessionID, streamID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// init is an internal helper function that ensures the nested map structure for a
|
||||
// given sessionID and streamID exists, creating it if necessary. It returns the
|
||||
// dataList associated with the specified IDs.
|
||||
// Requires s.mu.
|
||||
func (s *MemoryEventStore) init(sessionID, streamID string) *dataList {
|
||||
streamMap, ok := s.store[sessionID]
|
||||
if !ok {
|
||||
streamMap = make(map[string]*dataList)
|
||||
s.store[sessionID] = streamMap
|
||||
}
|
||||
dl, ok := streamMap[streamID]
|
||||
if !ok {
|
||||
dl = &dataList{}
|
||||
streamMap[streamID] = dl
|
||||
}
|
||||
return dl
|
||||
}
|
||||
|
||||
// Append implements [EventStore.Append] by recording data in memory.
|
||||
func (s *MemoryEventStore) Append(_ context.Context, sessionID, streamID string, data []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
dl := s.init(sessionID, streamID)
|
||||
// Purge before adding, so at least the current data item will be present.
|
||||
// (That could result in nBytes > maxBytes, but we'll live with that.)
|
||||
s.purge()
|
||||
dl.appendData(data)
|
||||
s.nBytes += len(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrEventsPurged is the error that [EventStore.After] should return if the event just after the
|
||||
// index is no longer available.
|
||||
var ErrEventsPurged = errors.New("data purged")
|
||||
|
||||
// errMalformedEvent is returned when an SSE event cannot be parsed due to format violations.
|
||||
// This is a hard error indicating corrupted data or protocol violations, as opposed to
|
||||
// transient I/O errors which may be retryable.
|
||||
var errMalformedEvent = errors.New("malformed event")
|
||||
|
||||
// After implements [EventStore.After].
|
||||
func (s *MemoryEventStore) After(_ context.Context, sessionID, streamID string, index int) iter.Seq2[[]byte, error] {
|
||||
// Return the data items to yield.
|
||||
// We must copy, because dataList.removeFirst nils out slice elements.
|
||||
copyData := func() ([][]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
streamMap, ok := s.store[sessionID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("MemoryEventStore.After: unknown session ID %q", sessionID)
|
||||
}
|
||||
dl, ok := streamMap[streamID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("MemoryEventStore.After: unknown stream ID %v in session %q", streamID, sessionID)
|
||||
}
|
||||
start := index + 1
|
||||
if dl.first > start {
|
||||
return nil, fmt.Errorf("MemoryEventStore.After: index %d, stream ID %v, session %q: %w",
|
||||
index, streamID, sessionID, ErrEventsPurged)
|
||||
}
|
||||
return slices.Clone(dl.data[start-dl.first:]), nil
|
||||
}
|
||||
|
||||
return func(yield func([]byte, error) bool) {
|
||||
ds, err := copyData()
|
||||
if err != nil {
|
||||
yield(nil, err)
|
||||
return
|
||||
}
|
||||
for _, d := range ds {
|
||||
if !yield(d, nil) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SessionClosed implements [EventStore.SessionClosed].
|
||||
func (s *MemoryEventStore) SessionClosed(_ context.Context, sessionID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, dl := range s.store[sessionID] {
|
||||
s.nBytes -= dl.size
|
||||
}
|
||||
delete(s.store, sessionID)
|
||||
s.validate()
|
||||
return nil
|
||||
}
|
||||
|
||||
// purge removes data until no more than s.maxBytes bytes are in use.
|
||||
// It must be called with s.mu held.
|
||||
func (s *MemoryEventStore) purge() {
|
||||
// Remove the first element of every dataList until below the max.
|
||||
for s.nBytes > s.maxBytes {
|
||||
changed := false
|
||||
for _, sm := range s.store {
|
||||
for _, dl := range sm {
|
||||
if dl.size > 0 {
|
||||
r := dl.removeFirst()
|
||||
if r > 0 {
|
||||
changed = true
|
||||
s.nBytes -= r
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
panic("no progress during purge")
|
||||
}
|
||||
}
|
||||
s.validate()
|
||||
}
|
||||
|
||||
// validate checks that the store's data structures are valid.
|
||||
// It must be called with s.mu held.
|
||||
func (s *MemoryEventStore) validate() {
|
||||
if !validateMemoryEventStore {
|
||||
return
|
||||
}
|
||||
// Check that we're accounting for the size correctly.
|
||||
n := 0
|
||||
for _, sm := range s.store {
|
||||
for _, dl := range sm {
|
||||
for _, d := range dl.data {
|
||||
n += len(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
if n != s.nBytes {
|
||||
panic("sizes don't add up")
|
||||
}
|
||||
}
|
||||
|
||||
// debugString returns a string containing the state of s.
|
||||
// Used in tests.
|
||||
func (s *MemoryEventStore) debugString() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var b strings.Builder
|
||||
for i, sess := range slices.Sorted(maps.Keys(s.store)) {
|
||||
if i > 0 {
|
||||
fmt.Fprintf(&b, "; ")
|
||||
}
|
||||
sm := s.store[sess]
|
||||
for i, sid := range slices.Sorted(maps.Keys(sm)) {
|
||||
if i > 0 {
|
||||
fmt.Fprintf(&b, "; ")
|
||||
}
|
||||
dl := sm[sid]
|
||||
fmt.Fprintf(&b, "%s %s first=%d", sess, sid, dl.first)
|
||||
for _, d := range dl.data {
|
||||
fmt.Fprintf(&b, " %s", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// 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 (
|
||||
"iter"
|
||||
"maps"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// This file contains implementations that are common to all features.
|
||||
// A feature is an item provided to a peer. In the 2025-03-26 spec,
|
||||
// the features are prompt, tool, resource and root.
|
||||
|
||||
// A featureSet is a collection of features of type T.
|
||||
// Every feature has a unique ID, and the spec never mentions
|
||||
// an ordering for the List calls, so what it calls a "list" is actually a set.
|
||||
//
|
||||
// An alternative implementation would use an ordered map, but that's probably
|
||||
// not necessary as adds and removes are rare, and usually batched.
|
||||
type featureSet[T any] struct {
|
||||
uniqueID func(T) string
|
||||
features map[string]T
|
||||
sortedKeys []string // lazily computed; nil after add or remove
|
||||
}
|
||||
|
||||
// newFeatureSet creates a new featureSet for features of type T.
|
||||
// The argument function should return the unique ID for a single feature.
|
||||
func newFeatureSet[T any](uniqueIDFunc func(T) string) *featureSet[T] {
|
||||
return &featureSet[T]{
|
||||
uniqueID: uniqueIDFunc,
|
||||
features: make(map[string]T),
|
||||
}
|
||||
}
|
||||
|
||||
// add adds each feature to the set if it is not present,
|
||||
// or replaces an existing feature.
|
||||
func (s *featureSet[T]) add(fs ...T) {
|
||||
for _, f := range fs {
|
||||
s.features[s.uniqueID(f)] = f
|
||||
}
|
||||
s.sortedKeys = nil
|
||||
}
|
||||
|
||||
// remove removes all features with the given uids from the set if present,
|
||||
// and returns whether any were removed.
|
||||
// It is not an error to remove a nonexistent feature.
|
||||
func (s *featureSet[T]) remove(uids ...string) bool {
|
||||
changed := false
|
||||
for _, uid := range uids {
|
||||
if _, ok := s.features[uid]; ok {
|
||||
changed = true
|
||||
delete(s.features, uid)
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
s.sortedKeys = nil
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// get returns the feature with the given uid.
|
||||
// If there is none, it returns zero, false.
|
||||
func (s *featureSet[T]) get(uid string) (T, bool) {
|
||||
t, ok := s.features[uid]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// len returns the number of features in the set.
|
||||
func (s *featureSet[T]) len() int { return len(s.features) }
|
||||
|
||||
// all returns an iterator over of all the features in the set
|
||||
// sorted by unique ID.
|
||||
func (s *featureSet[T]) all() iter.Seq[T] {
|
||||
s.sortKeys()
|
||||
return func(yield func(T) bool) {
|
||||
s.yieldFrom(0, yield)
|
||||
}
|
||||
}
|
||||
|
||||
// above returns an iterator over features in the set whose unique IDs are
|
||||
// greater than `uid`, in ascending ID order.
|
||||
func (s *featureSet[T]) above(uid string) iter.Seq[T] {
|
||||
s.sortKeys()
|
||||
index, found := slices.BinarySearch(s.sortedKeys, uid)
|
||||
if found {
|
||||
index++
|
||||
}
|
||||
return func(yield func(T) bool) {
|
||||
s.yieldFrom(index, yield)
|
||||
}
|
||||
}
|
||||
|
||||
// sortKeys is a helper that maintains a sorted list of feature IDs. It
|
||||
// computes this list lazily upon its first call after a modification, or
|
||||
// if it's nil.
|
||||
func (s *featureSet[T]) sortKeys() {
|
||||
if s.sortedKeys != nil {
|
||||
return
|
||||
}
|
||||
s.sortedKeys = slices.Sorted(maps.Keys(s.features))
|
||||
}
|
||||
|
||||
// yieldFrom is a helper that iterates over the features in the set,
|
||||
// starting at the given index, and calls the yield function for each one.
|
||||
func (s *featureSet[T]) yieldFrom(index int, yield func(T) bool) {
|
||||
for i := index; i < len(s.sortedKeys); i++ {
|
||||
if !yield(s.features[s.sortedKeys[i]]) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Logging levels.
|
||||
const (
|
||||
LevelDebug = slog.LevelDebug
|
||||
LevelInfo = slog.LevelInfo
|
||||
LevelNotice = (slog.LevelInfo + slog.LevelWarn) / 2
|
||||
LevelWarning = slog.LevelWarn
|
||||
LevelError = slog.LevelError
|
||||
LevelCritical = slog.LevelError + 4
|
||||
LevelAlert = slog.LevelError + 8
|
||||
LevelEmergency = slog.LevelError + 12
|
||||
)
|
||||
|
||||
var slogToMCP = map[slog.Level]LoggingLevel{
|
||||
LevelDebug: "debug",
|
||||
LevelInfo: "info",
|
||||
LevelNotice: "notice",
|
||||
LevelWarning: "warning",
|
||||
LevelError: "error",
|
||||
LevelCritical: "critical",
|
||||
LevelAlert: "alert",
|
||||
LevelEmergency: "emergency",
|
||||
}
|
||||
|
||||
var mcpToSlog = make(map[LoggingLevel]slog.Level)
|
||||
|
||||
func init() {
|
||||
for sl, ml := range slogToMCP {
|
||||
mcpToSlog[ml] = sl
|
||||
}
|
||||
}
|
||||
|
||||
func slogLevelToMCP(sl slog.Level) LoggingLevel {
|
||||
if ml, ok := slogToMCP[sl]; ok {
|
||||
return ml
|
||||
}
|
||||
return "debug" // for lack of a better idea
|
||||
}
|
||||
|
||||
func mcpLevelToSlog(ll LoggingLevel) slog.Level {
|
||||
if sl, ok := mcpToSlog[ll]; ok {
|
||||
return sl
|
||||
}
|
||||
// TODO: is there a better default?
|
||||
return LevelDebug
|
||||
}
|
||||
|
||||
// compareLevels behaves like [cmp.Compare] for [LoggingLevel]s.
|
||||
func compareLevels(l1, l2 LoggingLevel) int {
|
||||
return cmp.Compare(mcpLevelToSlog(l1), mcpLevelToSlog(l2))
|
||||
}
|
||||
|
||||
// LoggingHandlerOptions are options for a LoggingHandler.
|
||||
type LoggingHandlerOptions struct {
|
||||
// The value for the "logger" field of logging notifications.
|
||||
LoggerName string
|
||||
// Limits the rate at which log messages are sent.
|
||||
// Excess messages are dropped.
|
||||
// If zero, there is no rate limiting.
|
||||
MinInterval time.Duration
|
||||
}
|
||||
|
||||
// A LoggingHandler is a [slog.Handler] for MCP.
|
||||
type LoggingHandler struct {
|
||||
opts LoggingHandlerOptions
|
||||
ss *ServerSession
|
||||
// Ensures that the buffer reset is atomic with the write (see Handle).
|
||||
// A pointer so that clones share the mutex. See
|
||||
// https://github.com/golang/example/blob/master/slog-handler-guide/README.md#getting-the-mutex-right.
|
||||
mu *sync.Mutex
|
||||
lastMessageSent time.Time // for rate-limiting
|
||||
buf *bytes.Buffer
|
||||
handler slog.Handler
|
||||
}
|
||||
|
||||
// ensureLogger returns l if non-nil, otherwise a discard logger.
|
||||
func ensureLogger(l *slog.Logger) *slog.Logger {
|
||||
if l != nil {
|
||||
return l
|
||||
}
|
||||
return slog.New(slog.DiscardHandler)
|
||||
}
|
||||
|
||||
// NewLoggingHandler creates a [LoggingHandler] that logs to the given [ServerSession] using a
|
||||
// [slog.JSONHandler].
|
||||
func NewLoggingHandler(ss *ServerSession, opts *LoggingHandlerOptions) *LoggingHandler {
|
||||
var buf bytes.Buffer
|
||||
jsonHandler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{
|
||||
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
||||
// Remove level: it appears in LoggingMessageParams.
|
||||
if a.Key == slog.LevelKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
})
|
||||
lh := &LoggingHandler{
|
||||
ss: ss,
|
||||
mu: new(sync.Mutex),
|
||||
buf: &buf,
|
||||
handler: jsonHandler,
|
||||
}
|
||||
if opts != nil {
|
||||
lh.opts = *opts
|
||||
}
|
||||
return lh
|
||||
}
|
||||
|
||||
// Enabled implements [slog.Handler.Enabled] by comparing level to the [ServerSession]'s level.
|
||||
func (h *LoggingHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
// This is also checked in ServerSession.LoggingMessage, so checking it here
|
||||
// is just an optimization that skips building the JSON.
|
||||
h.ss.mu.Lock()
|
||||
mcpLevel := h.ss.state.LogLevel
|
||||
h.ss.mu.Unlock()
|
||||
return level >= mcpLevelToSlog(mcpLevel)
|
||||
}
|
||||
|
||||
// WithAttrs implements [slog.Handler.WithAttrs].
|
||||
func (h *LoggingHandler) WithAttrs(as []slog.Attr) slog.Handler {
|
||||
h2 := *h
|
||||
h2.handler = h.handler.WithAttrs(as)
|
||||
return &h2
|
||||
}
|
||||
|
||||
// WithGroup implements [slog.Handler.WithGroup].
|
||||
func (h *LoggingHandler) WithGroup(name string) slog.Handler {
|
||||
h2 := *h
|
||||
h2.handler = h.handler.WithGroup(name)
|
||||
return &h2
|
||||
}
|
||||
|
||||
// Handle implements [slog.Handler.Handle] by writing the Record to a JSONHandler,
|
||||
// then calling [ServerSession.LoggingMessage] with the result.
|
||||
func (h *LoggingHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
err := h.handle(ctx, r)
|
||||
// TODO(jba): find a way to surface the error.
|
||||
// The return value will probably be ignored.
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *LoggingHandler) handle(ctx context.Context, r slog.Record) error {
|
||||
// Observe the rate limit.
|
||||
// TODO(jba): use golang.org/x/time/rate.
|
||||
h.mu.Lock()
|
||||
skip := time.Since(h.lastMessageSent) < h.opts.MinInterval
|
||||
h.mu.Unlock()
|
||||
if skip {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
var data json.RawMessage
|
||||
// Make the buffer reset atomic with the record write.
|
||||
// We are careful here in the unlikely event that the handler panics.
|
||||
// We don't want to hold the lock for the entire function, because Notify is
|
||||
// an I/O operation.
|
||||
// This can result in out-of-order delivery.
|
||||
func() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.buf.Reset()
|
||||
err = h.handler.Handle(ctx, r)
|
||||
// Clone the buffer as Bytes() references the internal buffer.
|
||||
data = json.RawMessage(slices.Clone(h.buf.Bytes()))
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.lastMessageSent = time.Now()
|
||||
h.mu.Unlock()
|
||||
|
||||
params := &LoggingMessageParams{
|
||||
Logger: h.opts.LoggerName,
|
||||
Level: slogLevelToMCP(r.Level),
|
||||
Data: data,
|
||||
}
|
||||
// We pass the argument context to Notify, even though slog.Handler.Handle's
|
||||
// documentation says not to.
|
||||
// In this case logging is a service to clients, not a means for debugging the
|
||||
// server, so we want to cancel the log message.
|
||||
return h.ss.Log(ctx, params)
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
// 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.
|
||||
//
|
||||
// To get started, create either a [Client] or [Server], add features to it
|
||||
// using `AddXXX` functions, and connect it to a peer using a [Transport].
|
||||
//
|
||||
// For example, to run a simple server on the [StdioTransport]:
|
||||
//
|
||||
// server := mcp.NewServer(&mcp.Implementation{Name: "greeter"}, nil)
|
||||
//
|
||||
// // Using the generic AddTool automatically populates the the input and output
|
||||
// // schema of the tool.
|
||||
// type args struct {
|
||||
// Name string `json:"name" jsonschema:"the person to greet"`
|
||||
// }
|
||||
// mcp.AddTool(server, &mcp.Tool{
|
||||
// Name: "greet",
|
||||
// Description: "say hi",
|
||||
// }, func(ctx context.Context, req *mcp.CallToolRequest, args args) (*mcp.CallToolResult, any, error) {
|
||||
// return &mcp.CallToolResult{
|
||||
// Content: []mcp.Content{
|
||||
// &mcp.TextContent{Text: "Hi " + args.Name},
|
||||
// },
|
||||
// }, nil, nil
|
||||
// })
|
||||
//
|
||||
// // Run the server on the stdio transport.
|
||||
// if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
|
||||
// log.Printf("Server failed: %v", err)
|
||||
// }
|
||||
//
|
||||
// To connect to this server, use the [CommandTransport]:
|
||||
//
|
||||
// client := mcp.NewClient(&mcp.Implementation{Name: "mcp-client", Version: "v1.0.0"}, nil)
|
||||
// transport := &mcp.CommandTransport{Command: exec.Command("myserver")}
|
||||
// session, err := client.Connect(ctx, transport, nil)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// defer session.Close()
|
||||
//
|
||||
// params := &mcp.CallToolParams{
|
||||
// Name: "greet",
|
||||
// Arguments: map[string]any{"name": "you"},
|
||||
// }
|
||||
// res, err := session.CallTool(ctx, params)
|
||||
// if err != nil {
|
||||
// log.Fatalf("CallTool failed: %v", err)
|
||||
// }
|
||||
//
|
||||
// # Clients, servers, and sessions
|
||||
//
|
||||
// In this SDK, both a [Client] and [Server] may handle many concurrent
|
||||
// connections. Each time a client or server is connected to a peer using a
|
||||
// [Transport], it creates a new session (either a [ClientSession] or
|
||||
// [ServerSession]):
|
||||
//
|
||||
// Client Server
|
||||
// ⇅ (jsonrpc2) ⇅
|
||||
// ClientSession ⇄ Client Transport ⇄ Server Transport ⇄ ServerSession
|
||||
//
|
||||
// The session types expose an API to interact with its peer. For example,
|
||||
// [ClientSession.CallTool] or [ServerSession.ListRoots].
|
||||
//
|
||||
// # Adding features
|
||||
//
|
||||
// Add MCP servers to your Client or Server using AddXXX methods (for example
|
||||
// [Client.AddRoot] or [Server.AddPrompt]). If any peers are connected when
|
||||
// AddXXX is called, they will receive a corresponding change notification
|
||||
// (for example notifications/roots/list_changed).
|
||||
//
|
||||
// Adding tools is special: tools may be bound to ordinary Go functions by
|
||||
// using the top-level generic [AddTool] function, which allows specifying an
|
||||
// input and output type. When AddTool is used, the tool's input schema and
|
||||
// output schema are automatically populated, and inputs are automatically
|
||||
// validated. As a special case, if the output type is 'any', no output schema
|
||||
// is generated.
|
||||
//
|
||||
// func double(_ context.Context, _ *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) {
|
||||
// return nil, Out{Answer: 2*in.Number}, nil
|
||||
// }
|
||||
// ...
|
||||
// mcp.AddTool(server, &mcp.Tool{Name: "double"}, double)
|
||||
package mcp
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// A PromptHandler handles a call to prompts/get.
|
||||
type PromptHandler func(context.Context, *GetPromptRequest) (*GetPromptResult, error)
|
||||
|
||||
type serverPrompt struct {
|
||||
prompt *Prompt
|
||||
handler PromptHandler
|
||||
}
|
||||
+1622
File diff suppressed because it is too large
Load Diff
+39
@@ -0,0 +1,39 @@
|
||||
// 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.
|
||||
|
||||
// This file holds the request types.
|
||||
|
||||
package mcp
|
||||
|
||||
type (
|
||||
CallToolRequest = ServerRequest[*CallToolParamsRaw]
|
||||
CompleteRequest = ServerRequest[*CompleteParams]
|
||||
GetPromptRequest = ServerRequest[*GetPromptParams]
|
||||
InitializedRequest = ServerRequest[*InitializedParams]
|
||||
ListPromptsRequest = ServerRequest[*ListPromptsParams]
|
||||
ListResourcesRequest = ServerRequest[*ListResourcesParams]
|
||||
ListResourceTemplatesRequest = ServerRequest[*ListResourceTemplatesParams]
|
||||
ListToolsRequest = ServerRequest[*ListToolsParams]
|
||||
ProgressNotificationServerRequest = ServerRequest[*ProgressNotificationParams]
|
||||
ReadResourceRequest = ServerRequest[*ReadResourceParams]
|
||||
RootsListChangedRequest = ServerRequest[*RootsListChangedParams]
|
||||
SubscribeRequest = ServerRequest[*SubscribeParams]
|
||||
UnsubscribeRequest = ServerRequest[*UnsubscribeParams]
|
||||
)
|
||||
|
||||
type (
|
||||
CreateMessageRequest = ClientRequest[*CreateMessageParams]
|
||||
CreateMessageWithToolsRequest = ClientRequest[*CreateMessageWithToolsParams]
|
||||
ElicitRequest = ClientRequest[*ElicitParams]
|
||||
initializedClientRequest = ClientRequest[*InitializedParams]
|
||||
InitializeRequest = ClientRequest[*InitializeParams]
|
||||
ListRootsRequest = ClientRequest[*ListRootsParams]
|
||||
LoggingMessageRequest = ClientRequest[*LoggingMessageParams]
|
||||
ProgressNotificationClientRequest = ClientRequest[*ProgressNotificationParams]
|
||||
PromptListChangedRequest = ClientRequest[*PromptListChangedParams]
|
||||
ResourceListChangedRequest = ClientRequest[*ResourceListChangedParams]
|
||||
ResourceUpdatedNotificationRequest = ClientRequest[*ResourceUpdatedNotificationParams]
|
||||
ToolListChangedRequest = ClientRequest[*ToolListChangedParams]
|
||||
ElicitationCompleteNotificationRequest = ClientRequest[*ElicitationCompleteParams]
|
||||
)
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
// 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"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
"github.com/yosida95/uritemplate/v3"
|
||||
)
|
||||
|
||||
// A serverResource associates a Resource with its handler.
|
||||
type serverResource struct {
|
||||
resource *Resource
|
||||
handler ResourceHandler
|
||||
}
|
||||
|
||||
// A serverResourceTemplate associates a ResourceTemplate with its handler.
|
||||
type serverResourceTemplate struct {
|
||||
resourceTemplate *ResourceTemplate
|
||||
handler ResourceHandler
|
||||
}
|
||||
|
||||
// A ResourceHandler is a function that reads a resource.
|
||||
// It will be called when the client calls [ClientSession.ReadResource].
|
||||
// If it cannot find the resource, it should return the result of calling [ResourceNotFoundError].
|
||||
type ResourceHandler func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error)
|
||||
|
||||
// ResourceNotFoundError returns an error indicating that a resource being read could
|
||||
// not be found.
|
||||
func ResourceNotFoundError(uri string) error {
|
||||
return &jsonrpc.Error{
|
||||
Code: CodeResourceNotFound,
|
||||
Message: "Resource not found",
|
||||
Data: json.RawMessage(fmt.Sprintf(`{"uri":%q}`, uri)),
|
||||
}
|
||||
}
|
||||
|
||||
// readFileResource reads from the filesystem at a URI relative to dirFilepath, respecting
|
||||
// the roots.
|
||||
// dirFilepath and rootFilepaths are absolute filesystem paths.
|
||||
func readFileResource(rawURI, dirFilepath string, rootFilepaths []string) ([]byte, error) {
|
||||
uriFilepath, err := computeURIFilepath(rawURI, dirFilepath, rootFilepaths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data []byte
|
||||
err = withFile(dirFilepath, uriFilepath, func(f *os.File) error {
|
||||
var err error
|
||||
data, err = io.ReadAll(f)
|
||||
return err
|
||||
})
|
||||
if os.IsNotExist(err) {
|
||||
err = ResourceNotFoundError(rawURI)
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
// computeURIFilepath returns a path relative to dirFilepath.
|
||||
// The dirFilepath and rootFilepaths are absolute file paths.
|
||||
func computeURIFilepath(rawURI, dirFilepath string, rootFilepaths []string) (string, error) {
|
||||
// We use "file path" to mean a filesystem path.
|
||||
uri, err := url.Parse(rawURI)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if uri.Scheme != "file" {
|
||||
return "", fmt.Errorf("URI is not a file: %s", uri)
|
||||
}
|
||||
if uri.Path == "" {
|
||||
// A more specific error than the one below, to catch the
|
||||
// common mistake "file://foo".
|
||||
return "", errors.New("empty path")
|
||||
}
|
||||
// The URI's path is interpreted relative to dirFilepath, and in the local filesystem.
|
||||
// It must not try to escape its directory.
|
||||
uriFilepathRel, err := filepath.Localize(strings.TrimPrefix(uri.Path, "/"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%q cannot be localized: %w", uriFilepathRel, err)
|
||||
}
|
||||
|
||||
// Check roots, if there are any.
|
||||
if len(rootFilepaths) > 0 {
|
||||
// To check against the roots, we need an absolute file path, not relative to the directory.
|
||||
// uriFilepath is local, so the joined path is under dirFilepath.
|
||||
uriFilepathAbs := filepath.Join(dirFilepath, uriFilepathRel)
|
||||
rootOK := false
|
||||
// Check that the requested file path is under some root.
|
||||
// Since both paths are absolute, that's equivalent to filepath.Rel constructing
|
||||
// a local path.
|
||||
for _, rootFilepathAbs := range rootFilepaths {
|
||||
if rel, err := filepath.Rel(rootFilepathAbs, uriFilepathAbs); err == nil && filepath.IsLocal(rel) {
|
||||
rootOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !rootOK {
|
||||
return "", fmt.Errorf("URI path %q is not under any root", uriFilepathAbs)
|
||||
}
|
||||
}
|
||||
return uriFilepathRel, nil
|
||||
}
|
||||
|
||||
// withFile calls f on the file at join(dir, rel),
|
||||
// protecting against path traversal attacks.
|
||||
func withFile(dir, rel string, f func(*os.File) error) (err error) {
|
||||
r, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
file, err := r.Open(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Record error, in case f writes.
|
||||
defer func() { err = errors.Join(err, file.Close()) }()
|
||||
return f(file)
|
||||
}
|
||||
|
||||
// fileRoots transforms the Roots obtained from the client into absolute paths on
|
||||
// the local filesystem.
|
||||
// TODO(jba): expose this functionality to user ResourceHandlers,
|
||||
// so they don't have to repeat it.
|
||||
func fileRoots(rawRoots []*Root) ([]string, error) {
|
||||
var fileRoots []string
|
||||
for _, r := range rawRoots {
|
||||
fr, err := fileRoot(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileRoots = append(fileRoots, fr)
|
||||
}
|
||||
return fileRoots, nil
|
||||
}
|
||||
|
||||
// fileRoot returns the absolute path for Root.
|
||||
func fileRoot(root *Root) (_ string, err error) {
|
||||
defer util.Wrapf(&err, "root %q", root.URI)
|
||||
|
||||
// Convert to absolute file path.
|
||||
rurl, err := url.Parse(root.URI)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rurl.Scheme != "file" {
|
||||
return "", errors.New("not a file URI")
|
||||
}
|
||||
if rurl.Path == "" {
|
||||
// A more specific error than the one below, to catch the
|
||||
// common mistake "file://foo".
|
||||
return "", errors.New("empty path")
|
||||
}
|
||||
// We don't want Localize here: we want an absolute path, which is not local.
|
||||
fileRoot := filepath.Clean(filepath.FromSlash(rurl.Path))
|
||||
if !filepath.IsAbs(fileRoot) {
|
||||
return "", errors.New("not an absolute path")
|
||||
}
|
||||
return fileRoot, nil
|
||||
}
|
||||
|
||||
// Matches reports whether the receiver's uri template matches the uri.
|
||||
func (sr *serverResourceTemplate) Matches(uri string) bool {
|
||||
tmpl, err := uritemplate.New(sr.resourceTemplate.URITemplate)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return tmpl.Regexp().MatchString(uri)
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
// 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 (
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
)
|
||||
|
||||
// A SchemaCache caches JSON schemas to avoid repeated reflection and resolution.
|
||||
//
|
||||
// This is useful for stateless server deployments (one [Server] per request)
|
||||
// where tools are re-registered on every request. Without caching, each
|
||||
// [AddTool] call triggers expensive reflection-based schema generation.
|
||||
//
|
||||
// A SchemaCache is safe for concurrent use by multiple goroutines.
|
||||
//
|
||||
// # Trade-offs
|
||||
//
|
||||
// The cache is unbounded: it stores one entry per unique Go type or schema
|
||||
// pointer. For typical MCP servers with a fixed set of tools, memory usage
|
||||
// is negligible. However, if tool input types are generated dynamically,
|
||||
// the cache will grow without bound.
|
||||
//
|
||||
// The cache uses pointer identity for pre-defined schemas. If a schema's
|
||||
// contents change but the pointer remains the same, stale resolved schemas
|
||||
// may be returned. In practice, this is not an issue because tool schemas
|
||||
// are typically defined once at startup.
|
||||
type SchemaCache struct {
|
||||
byType sync.Map // reflect.Type -> *cachedSchema
|
||||
bySchema sync.Map // *jsonschema.Schema -> *jsonschema.Resolved
|
||||
}
|
||||
|
||||
type cachedSchema struct {
|
||||
schema *jsonschema.Schema
|
||||
resolved *jsonschema.Resolved
|
||||
}
|
||||
|
||||
// NewSchemaCache creates a new [SchemaCache].
|
||||
func NewSchemaCache() *SchemaCache {
|
||||
return &SchemaCache{}
|
||||
}
|
||||
|
||||
func (c *SchemaCache) getByType(t reflect.Type) (*jsonschema.Schema, *jsonschema.Resolved, bool) {
|
||||
if v, ok := c.byType.Load(t); ok {
|
||||
cs := v.(*cachedSchema)
|
||||
return cs.schema, cs.resolved, true
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
func (c *SchemaCache) setByType(t reflect.Type, schema *jsonschema.Schema, resolved *jsonschema.Resolved) {
|
||||
c.byType.Store(t, &cachedSchema{schema: schema, resolved: resolved})
|
||||
}
|
||||
|
||||
func (c *SchemaCache) getBySchema(schema *jsonschema.Schema) (*jsonschema.Resolved, bool) {
|
||||
if v, ok := c.bySchema.Load(schema); ok {
|
||||
return v.(*jsonschema.Resolved), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (c *SchemaCache) setBySchema(schema *jsonschema.Schema, resolved *jsonschema.Resolved) {
|
||||
c.bySchema.Store(schema, resolved)
|
||||
}
|
||||
+1595
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
// 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
|
||||
|
||||
// hasSessionID is the interface which, if implemented by connections, informs
|
||||
// the session about their session ID.
|
||||
//
|
||||
// TODO(rfindley): remove SessionID methods from connections, when it doesn't
|
||||
// make sense. Or remove it from the Sessions entirely: why does it even need
|
||||
// to be exposed?
|
||||
type hasSessionID interface {
|
||||
SessionID() string
|
||||
}
|
||||
|
||||
// ServerSessionState is the state of a session.
|
||||
type ServerSessionState struct {
|
||||
// InitializeParams are the parameters from 'initialize'.
|
||||
InitializeParams *InitializeParams `json:"initializeParams"`
|
||||
|
||||
// InitializedParams are the parameters from 'notifications/initialized'.
|
||||
InitializedParams *InitializedParams `json:"initializedParams"`
|
||||
|
||||
// LogLevel is the logging level for the session.
|
||||
LogLevel LoggingLevel `json:"logLevel"`
|
||||
|
||||
// TODO: resource subscriptions
|
||||
}
|
||||
+611
@@ -0,0 +1,611 @@
|
||||
// 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.
|
||||
|
||||
// This file contains code shared between client and server, including
|
||||
// method handler and middleware definitions.
|
||||
//
|
||||
// Much of this is here so that we can factor out commonalities using
|
||||
// generics. If this becomes unwieldy, it can perhaps be simplified with
|
||||
// reflection.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/auth"
|
||||
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
)
|
||||
|
||||
const (
|
||||
// latestProtocolVersion is the latest protocol version that this version of
|
||||
// the SDK supports.
|
||||
//
|
||||
// It is the version that the client sends in the initialization request, and
|
||||
// the default version used by the server.
|
||||
latestProtocolVersion = protocolVersion20250618
|
||||
protocolVersion20251125 = "2025-11-25" // not yet released
|
||||
protocolVersion20250618 = "2025-06-18"
|
||||
protocolVersion20250326 = "2025-03-26"
|
||||
protocolVersion20241105 = "2024-11-05"
|
||||
)
|
||||
|
||||
var supportedProtocolVersions = []string{
|
||||
protocolVersion20251125,
|
||||
protocolVersion20250618,
|
||||
protocolVersion20250326,
|
||||
protocolVersion20241105,
|
||||
}
|
||||
|
||||
// negotiatedVersion returns the effective protocol version to use, given a
|
||||
// client version.
|
||||
func negotiatedVersion(clientVersion string) string {
|
||||
// In general, prefer to use the clientVersion, but if we don't support the
|
||||
// client's version, use the latest version.
|
||||
//
|
||||
// This handles the case where a new spec version is released, and the SDK
|
||||
// does not support it yet.
|
||||
if !slices.Contains(supportedProtocolVersions, clientVersion) {
|
||||
return latestProtocolVersion
|
||||
}
|
||||
return clientVersion
|
||||
}
|
||||
|
||||
// A MethodHandler handles MCP messages.
|
||||
// For methods, exactly one of the return values must be nil.
|
||||
// For notifications, both must be nil.
|
||||
type MethodHandler func(ctx context.Context, method string, req Request) (result Result, err error)
|
||||
|
||||
// A Session is either a [ClientSession] or a [ServerSession].
|
||||
type Session interface {
|
||||
// ID returns the session ID, or the empty string if there is none.
|
||||
ID() string
|
||||
|
||||
sendingMethodInfos() map[string]methodInfo
|
||||
receivingMethodInfos() map[string]methodInfo
|
||||
sendingMethodHandler() MethodHandler
|
||||
receivingMethodHandler() MethodHandler
|
||||
getConn() *jsonrpc2.Connection
|
||||
}
|
||||
|
||||
// Middleware is a function from [MethodHandler] to [MethodHandler].
|
||||
type Middleware func(MethodHandler) MethodHandler
|
||||
|
||||
// addMiddleware wraps the handler in the middleware functions.
|
||||
func addMiddleware(handlerp *MethodHandler, middleware []Middleware) {
|
||||
for _, m := range slices.Backward(middleware) {
|
||||
*handlerp = m(*handlerp)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultSendingMethodHandler(ctx context.Context, method string, req Request) (Result, error) {
|
||||
info, ok := req.GetSession().sendingMethodInfos()[method]
|
||||
if !ok {
|
||||
// This can be called from user code, with an arbitrary value for method.
|
||||
return nil, jsonrpc2.ErrNotHandled
|
||||
}
|
||||
params := req.GetParams()
|
||||
if initParams, ok := params.(*InitializeParams); ok {
|
||||
// Fix the marshaling of initialize params, to work around #607.
|
||||
//
|
||||
// The initialize params we produce should never be nil, nor have nil
|
||||
// capabilities, so any panic here is a bug.
|
||||
params = initParams.toV2()
|
||||
}
|
||||
// Notifications don't have results.
|
||||
if strings.HasPrefix(method, "notifications/") {
|
||||
return nil, req.GetSession().getConn().Notify(ctx, method, params)
|
||||
}
|
||||
// Create the result to unmarshal into.
|
||||
// The concrete type of the result is the return type of the receiving function.
|
||||
res := info.newResult()
|
||||
if err := call(ctx, req.GetSession().getConn(), method, params, res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Helper method to avoid typed nil.
|
||||
func orZero[T any, P *U, U any](p P) T {
|
||||
if p == nil {
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
return any(p).(T)
|
||||
}
|
||||
|
||||
func handleNotify(ctx context.Context, method string, req Request) error {
|
||||
mh := req.GetSession().sendingMethodHandler()
|
||||
_, err := mh(ctx, method, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func handleSend[R Result](ctx context.Context, method string, req Request) (R, error) {
|
||||
mh := req.GetSession().sendingMethodHandler()
|
||||
// mh might be user code, so ensure that it returns the right values for the jsonrpc2 protocol.
|
||||
res, err := mh(ctx, method, req)
|
||||
if err != nil {
|
||||
var z R
|
||||
return z, err
|
||||
}
|
||||
return res.(R), nil
|
||||
}
|
||||
|
||||
// defaultReceivingMethodHandler is the initial MethodHandler for servers and clients, before being wrapped by middleware.
|
||||
func defaultReceivingMethodHandler[S Session](ctx context.Context, method string, req Request) (Result, error) {
|
||||
info, ok := req.GetSession().receivingMethodInfos()[method]
|
||||
if !ok {
|
||||
// This can be called from user code, with an arbitrary value for method.
|
||||
return nil, jsonrpc2.ErrNotHandled
|
||||
}
|
||||
return info.handleMethod(ctx, method, req)
|
||||
}
|
||||
|
||||
func handleReceive[S Session](ctx context.Context, session S, jreq *jsonrpc.Request) (Result, error) {
|
||||
info, err := checkRequest(jreq, session.receivingMethodInfos())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params, err := info.unmarshalParams(jreq.Params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("handling '%s': %w", jreq.Method, err)
|
||||
}
|
||||
|
||||
mh := session.receivingMethodHandler()
|
||||
re, _ := jreq.Extra.(*RequestExtra)
|
||||
req := info.newRequest(session, params, re)
|
||||
// mh might be user code, so ensure that it returns the right values for the jsonrpc2 protocol.
|
||||
res, err := mh(ctx, jreq.Method, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// checkRequest checks the given request against the provided method info, to
|
||||
// ensure it is a valid MCP request.
|
||||
//
|
||||
// If valid, the relevant method info is returned. Otherwise, a non-nil error
|
||||
// is returned describing why the request is invalid.
|
||||
//
|
||||
// This is extracted from request handling so that it can be called in the
|
||||
// transport layer to preemptively reject bad requests.
|
||||
func checkRequest(req *jsonrpc.Request, infos map[string]methodInfo) (methodInfo, error) {
|
||||
info, ok := infos[req.Method]
|
||||
if !ok {
|
||||
return methodInfo{}, fmt.Errorf("%w: %q unsupported", jsonrpc2.ErrNotHandled, req.Method)
|
||||
}
|
||||
if info.flags¬ification != 0 && req.IsCall() {
|
||||
return methodInfo{}, fmt.Errorf("%w: unexpected id for %q", jsonrpc2.ErrInvalidRequest, req.Method)
|
||||
}
|
||||
if info.flags¬ification == 0 && !req.IsCall() {
|
||||
return methodInfo{}, fmt.Errorf("%w: missing id for %q", jsonrpc2.ErrInvalidRequest, req.Method)
|
||||
}
|
||||
// missingParamsOK is checked here to catch the common case where "params" is
|
||||
// missing entirely.
|
||||
//
|
||||
// However, it's checked again after unmarshalling to catch the rare but
|
||||
// possible case where "params" is JSON null (see https://go.dev/issue/33835).
|
||||
if info.flags&missingParamsOK == 0 && len(req.Params) == 0 {
|
||||
return methodInfo{}, fmt.Errorf("%w: missing required \"params\"", jsonrpc2.ErrInvalidRequest)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// methodInfo is information about sending and receiving a method.
|
||||
type methodInfo struct {
|
||||
// flags is a collection of flags controlling how the JSONRPC method is
|
||||
// handled. See individual flag values for documentation.
|
||||
flags methodFlags
|
||||
// Unmarshal params from the wire into a Params struct.
|
||||
// Used on the receive side.
|
||||
unmarshalParams func(json.RawMessage) (Params, error)
|
||||
newRequest func(Session, Params, *RequestExtra) Request
|
||||
// Run the code when a call to the method is received.
|
||||
// Used on the receive side.
|
||||
handleMethod MethodHandler
|
||||
// Create a pointer to a Result struct.
|
||||
// Used on the send side.
|
||||
newResult func() Result
|
||||
}
|
||||
|
||||
// The following definitions support converting from typed to untyped method handlers.
|
||||
// Type parameter meanings:
|
||||
// - S: sessions
|
||||
// - P: params
|
||||
// - R: results
|
||||
|
||||
// A typedMethodHandler is like a MethodHandler, but with type information.
|
||||
type (
|
||||
typedClientMethodHandler[P Params, R Result] func(context.Context, *ClientRequest[P]) (R, error)
|
||||
typedServerMethodHandler[P Params, R Result] func(context.Context, *ServerRequest[P]) (R, error)
|
||||
)
|
||||
|
||||
type paramsPtr[T any] interface {
|
||||
*T
|
||||
Params
|
||||
}
|
||||
|
||||
type methodFlags int
|
||||
|
||||
const (
|
||||
notification methodFlags = 1 << iota // method is a notification, not request
|
||||
missingParamsOK // params may be missing or null
|
||||
)
|
||||
|
||||
func newClientMethodInfo[P paramsPtr[T], R Result, T any](d typedClientMethodHandler[P, R], flags methodFlags) methodInfo {
|
||||
mi := newMethodInfo[P, R](flags)
|
||||
mi.newRequest = func(s Session, p Params, _ *RequestExtra) Request {
|
||||
r := &ClientRequest[P]{Session: s.(*ClientSession)}
|
||||
if p != nil {
|
||||
r.Params = p.(P)
|
||||
}
|
||||
return r
|
||||
}
|
||||
mi.handleMethod = MethodHandler(func(ctx context.Context, _ string, req Request) (Result, error) {
|
||||
return d(ctx, req.(*ClientRequest[P]))
|
||||
})
|
||||
return mi
|
||||
}
|
||||
|
||||
func newServerMethodInfo[P paramsPtr[T], R Result, T any](d typedServerMethodHandler[P, R], flags methodFlags) methodInfo {
|
||||
mi := newMethodInfo[P, R](flags)
|
||||
mi.newRequest = func(s Session, p Params, re *RequestExtra) Request {
|
||||
r := &ServerRequest[P]{Session: s.(*ServerSession), Extra: re}
|
||||
if p != nil {
|
||||
r.Params = p.(P)
|
||||
}
|
||||
return r
|
||||
}
|
||||
mi.handleMethod = MethodHandler(func(ctx context.Context, _ string, req Request) (Result, error) {
|
||||
return d(ctx, req.(*ServerRequest[P]))
|
||||
})
|
||||
return mi
|
||||
}
|
||||
|
||||
// newMethodInfo creates a methodInfo from a typedMethodHandler.
|
||||
//
|
||||
// If isRequest is set, the method is treated as a request rather than a
|
||||
// notification.
|
||||
func newMethodInfo[P paramsPtr[T], R Result, T any](flags methodFlags) methodInfo {
|
||||
return methodInfo{
|
||||
flags: flags,
|
||||
unmarshalParams: func(m json.RawMessage) (Params, error) {
|
||||
var p P
|
||||
if m != nil {
|
||||
if err := internaljson.Unmarshal(m, &p); err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, p, err)
|
||||
}
|
||||
}
|
||||
// We must check missingParamsOK here, in addition to checkRequest, to
|
||||
// catch the edge cases where "params" is set to JSON null.
|
||||
// See also https://go.dev/issue/33835.
|
||||
//
|
||||
// We need to ensure that p is non-null to guard against crashes, as our
|
||||
// internal code or externally provided handlers may assume that params
|
||||
// is non-null.
|
||||
if flags&missingParamsOK == 0 && p == nil {
|
||||
return nil, fmt.Errorf("%w: missing required \"params\"", jsonrpc2.ErrInvalidRequest)
|
||||
}
|
||||
return orZero[Params](p), nil
|
||||
},
|
||||
// newResult is used on the send side, to construct the value to unmarshal the result into.
|
||||
// R is a pointer to a result struct. There is no way to "unpointer" it without reflection.
|
||||
// TODO(jba): explore generic approaches to this, perhaps by treating R in
|
||||
// the signature as the unpointered type.
|
||||
newResult: func() Result { return reflect.New(reflect.TypeFor[R]().Elem()).Interface().(R) },
|
||||
}
|
||||
}
|
||||
|
||||
// serverMethod is glue for creating a typedMethodHandler from a method on Server.
|
||||
func serverMethod[P Params, R Result](
|
||||
f func(*Server, context.Context, *ServerRequest[P]) (R, error),
|
||||
) typedServerMethodHandler[P, R] {
|
||||
return func(ctx context.Context, req *ServerRequest[P]) (R, error) {
|
||||
return f(req.Session.server, ctx, req)
|
||||
}
|
||||
}
|
||||
|
||||
// clientMethod is glue for creating a typedMethodHandler from a method on Client.
|
||||
func clientMethod[P Params, R Result](
|
||||
f func(*Client, context.Context, *ClientRequest[P]) (R, error),
|
||||
) typedClientMethodHandler[P, R] {
|
||||
return func(ctx context.Context, req *ClientRequest[P]) (R, error) {
|
||||
return f(req.Session.client, ctx, req)
|
||||
}
|
||||
}
|
||||
|
||||
// serverSessionMethod is glue for creating a typedServerMethodHandler from a method on ServerSession.
|
||||
func serverSessionMethod[P Params, R Result](f func(*ServerSession, context.Context, P) (R, error)) typedServerMethodHandler[P, R] {
|
||||
return func(ctx context.Context, req *ServerRequest[P]) (R, error) {
|
||||
return f(req.GetSession().(*ServerSession), ctx, req.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// clientSessionMethod is glue for creating a typedMethodHandler from a method on ServerSession.
|
||||
func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Context, P) (R, error)) typedClientMethodHandler[P, R] {
|
||||
return func(ctx context.Context, req *ClientRequest[P]) (R, error) {
|
||||
return f(req.GetSession().(*ClientSession), ctx, req.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// MCP-specific error codes.
|
||||
const (
|
||||
// CodeResourceNotFound indicates that a requested resource could not be found.
|
||||
CodeResourceNotFound = -32002
|
||||
// CodeURLElicitationRequired indicates that the server requires URL elicitation
|
||||
// before processing the request. The client should execute the elicitation handler
|
||||
// with the elicitations provided in the error data.
|
||||
CodeURLElicitationRequired = -32042
|
||||
)
|
||||
|
||||
// URLElicitationRequiredError returns an error indicating that URL elicitation is required
|
||||
// before the request can be processed. The elicitations parameter should contain the
|
||||
// elicitation requests that must be completed.
|
||||
func URLElicitationRequiredError(elicitations []*ElicitParams) error {
|
||||
// Validate that all elicitations are URL mode
|
||||
for _, elicit := range elicitations {
|
||||
mode := elicit.Mode
|
||||
if mode == "" {
|
||||
mode = "form" // default mode
|
||||
}
|
||||
if mode != "url" {
|
||||
panic(fmt.Sprintf("URLElicitationRequiredError requires all elicitations to be URL mode, got %q", mode))
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(map[string]any{
|
||||
"elicitations": elicitations,
|
||||
})
|
||||
if err != nil {
|
||||
// This should never happen with valid ElicitParams
|
||||
panic(fmt.Sprintf("failed to marshal elicitations: %v", err))
|
||||
}
|
||||
return &jsonrpc.Error{
|
||||
Code: CodeURLElicitationRequired,
|
||||
Message: "URL elicitation required",
|
||||
Data: json.RawMessage(data),
|
||||
}
|
||||
}
|
||||
|
||||
// Internal error codes
|
||||
const (
|
||||
// The error code if the method exists and was called properly, but the peer does not support it.
|
||||
//
|
||||
// TODO(rfindley): this code is wrong, and we should fix it to be
|
||||
// consistent with other SDKs.
|
||||
codeUnsupportedMethod = -31001
|
||||
)
|
||||
|
||||
// notifySessions calls Notify on all the sessions.
|
||||
// Should be called on a copy of the peer sessions.
|
||||
// The logger must be non-nil.
|
||||
func notifySessions[S Session, P Params](sessions []S, method string, params P, logger *slog.Logger) {
|
||||
if sessions == nil {
|
||||
return
|
||||
}
|
||||
// Notify with the background context, so the messages are sent on the
|
||||
// standalone stream.
|
||||
// TODO: make this timeout configurable, or call handleNotify asynchronously.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// TODO: there's a potential spec violation here, when the feature list
|
||||
// changes before the session (client or server) is initialized.
|
||||
for _, s := range sessions {
|
||||
req := newRequest(s, params)
|
||||
if err := handleNotify(ctx, method, req); err != nil {
|
||||
logger.Warn(fmt.Sprintf("calling %s: %v", method, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newRequest[S Session, P Params](s S, p P) Request {
|
||||
switch s := any(s).(type) {
|
||||
case *ClientSession:
|
||||
return &ClientRequest[P]{Session: s, Params: p}
|
||||
case *ServerSession:
|
||||
return &ServerRequest[P]{Session: s, Params: p}
|
||||
default:
|
||||
panic("bad session")
|
||||
}
|
||||
}
|
||||
|
||||
// Meta is additional metadata for requests, responses and other types.
|
||||
type Meta map[string]any
|
||||
|
||||
// GetMeta returns metadata from a value.
|
||||
func (m Meta) GetMeta() map[string]any { return m }
|
||||
|
||||
// SetMeta sets the metadata on a value.
|
||||
func (m *Meta) SetMeta(x map[string]any) { *m = x }
|
||||
|
||||
const progressTokenKey = "progressToken"
|
||||
|
||||
func getProgressToken(p Params) any {
|
||||
return p.GetMeta()[progressTokenKey]
|
||||
}
|
||||
|
||||
func setProgressToken(p Params, pt any) {
|
||||
switch pt.(type) {
|
||||
// Support int32 and int64 for atomic.IntNN.
|
||||
case int, int32, int64, string:
|
||||
default:
|
||||
panic(fmt.Sprintf("progress token %v is of type %[1]T, not int or string", pt))
|
||||
}
|
||||
m := p.GetMeta()
|
||||
if m == nil {
|
||||
m = map[string]any{}
|
||||
}
|
||||
m[progressTokenKey] = pt
|
||||
}
|
||||
|
||||
// A Request is a method request with parameters and additional information, such as the session.
|
||||
// Request is implemented by [*ClientRequest] and [*ServerRequest].
|
||||
type Request interface {
|
||||
isRequest()
|
||||
GetSession() Session
|
||||
GetParams() Params
|
||||
// GetExtra returns the Extra field for ServerRequests, and nil for ClientRequests.
|
||||
GetExtra() *RequestExtra
|
||||
}
|
||||
|
||||
// A ClientRequest is a request to a client.
|
||||
type ClientRequest[P Params] struct {
|
||||
Session *ClientSession
|
||||
Params P
|
||||
}
|
||||
|
||||
// A ServerRequest is a request to a server.
|
||||
type ServerRequest[P Params] struct {
|
||||
Session *ServerSession
|
||||
Params P
|
||||
Extra *RequestExtra
|
||||
}
|
||||
|
||||
// RequestExtra is extra information included in requests, typically from
|
||||
// the transport layer.
|
||||
type RequestExtra struct {
|
||||
TokenInfo *auth.TokenInfo // bearer token info (e.g. from OAuth) if any
|
||||
Header http.Header // header from HTTP request, if any
|
||||
|
||||
// If set, CloseSSEStream explicitly closes the current SSE request stream.
|
||||
//
|
||||
// [SEP-1699] introduced server-side SSE stream disconnection: for
|
||||
// long-running requests, servers may opt to close the SSE stream and
|
||||
// ask the client to retry at a later time. CloseSSEStream implements this
|
||||
// feature; if RetryAfter is set, an event is sent with a `retry:` field
|
||||
// to configure the reconnection delay.
|
||||
//
|
||||
// [SEP-1699]: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699
|
||||
CloseSSEStream func(CloseSSEStreamArgs)
|
||||
}
|
||||
|
||||
// CloseSSEStreamArgs are arguments for [RequestExtra.CloseSSEStream].
|
||||
type CloseSSEStreamArgs struct {
|
||||
// RetryAfter configures the reconnection delay sent to the client via the
|
||||
// SSE retry field. If zero, no retry field is sent.
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (*ClientRequest[P]) isRequest() {}
|
||||
func (*ServerRequest[P]) isRequest() {}
|
||||
|
||||
func (r *ClientRequest[P]) GetSession() Session { return r.Session }
|
||||
func (r *ServerRequest[P]) GetSession() Session { return r.Session }
|
||||
|
||||
func (r *ClientRequest[P]) GetParams() Params { return r.Params }
|
||||
func (r *ServerRequest[P]) GetParams() Params { return r.Params }
|
||||
|
||||
func (r *ClientRequest[P]) GetExtra() *RequestExtra { return nil }
|
||||
func (r *ServerRequest[P]) GetExtra() *RequestExtra { return r.Extra }
|
||||
|
||||
func serverRequestFor[P Params](s *ServerSession, p P) *ServerRequest[P] {
|
||||
return &ServerRequest[P]{Session: s, Params: p}
|
||||
}
|
||||
|
||||
func clientRequestFor[P Params](s *ClientSession, p P) *ClientRequest[P] {
|
||||
return &ClientRequest[P]{Session: s, Params: p}
|
||||
}
|
||||
|
||||
// Params is a parameter (input) type for an MCP call or notification.
|
||||
type Params interface {
|
||||
// GetMeta returns metadata from a value.
|
||||
GetMeta() map[string]any
|
||||
// SetMeta sets the metadata on a value.
|
||||
SetMeta(map[string]any)
|
||||
|
||||
// isParams discourages implementation of Params outside of this package.
|
||||
isParams()
|
||||
}
|
||||
|
||||
// RequestParams is a parameter (input) type for an MCP request.
|
||||
type RequestParams interface {
|
||||
Params
|
||||
|
||||
// GetProgressToken returns the progress token from the params' Meta field, or nil
|
||||
// if there is none.
|
||||
GetProgressToken() any
|
||||
|
||||
// SetProgressToken sets the given progress token into the params' Meta field.
|
||||
// It panics if its argument is not an int or a string.
|
||||
SetProgressToken(any)
|
||||
}
|
||||
|
||||
// Result is a result of an MCP call.
|
||||
type Result interface {
|
||||
// isResult discourages implementation of Result outside of this package.
|
||||
isResult()
|
||||
|
||||
// GetMeta returns metadata from a value.
|
||||
GetMeta() map[string]any
|
||||
// SetMeta sets the metadata on a value.
|
||||
SetMeta(map[string]any)
|
||||
}
|
||||
|
||||
// emptyResult is returned by methods that have no result, like ping.
|
||||
// Those methods cannot return nil, because jsonrpc2 cannot handle nils.
|
||||
type emptyResult struct{}
|
||||
|
||||
func (*emptyResult) isResult() {}
|
||||
func (*emptyResult) GetMeta() map[string]any { panic("should never be called") }
|
||||
func (*emptyResult) SetMeta(map[string]any) { panic("should never be called") }
|
||||
|
||||
type listParams interface {
|
||||
// Returns a pointer to the param's Cursor field.
|
||||
cursorPtr() *string
|
||||
}
|
||||
|
||||
type listResult[T any] interface {
|
||||
// Returns a pointer to the param's NextCursor field.
|
||||
nextCursorPtr() *string
|
||||
}
|
||||
|
||||
// keepaliveSession represents a session that supports keepalive functionality.
|
||||
type keepaliveSession interface {
|
||||
Ping(ctx context.Context, params *PingParams) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// startKeepalive starts the keepalive mechanism for a session.
|
||||
// It assigns the cancel function to the provided cancelPtr and starts a goroutine
|
||||
// that sends ping messages at the specified interval.
|
||||
func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr *context.CancelFunc) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// Assign cancel function before starting goroutine to avoid race condition.
|
||||
// We cannot return it because the caller may need to cancel during the
|
||||
// window between goroutine scheduling and function return.
|
||||
*cancelPtr = cancel
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
pingCtx, pingCancel := context.WithTimeout(context.Background(), interval/2)
|
||||
err := session.Ping(pingCtx, nil)
|
||||
pingCancel()
|
||||
if err != nil {
|
||||
// Ping failed, close the session
|
||||
_ = session.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
+489
@@ -0,0 +1,489 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
)
|
||||
|
||||
// This file implements support for SSE (HTTP with server-sent events)
|
||||
// transport server and client.
|
||||
// https://modelcontextprotocol.io/specification/2024-11-05/basic/transports
|
||||
//
|
||||
// The transport is simple, at least relative to the new streamable transport
|
||||
// introduced in the 2025-03-26 version of the spec. In short:
|
||||
//
|
||||
// 1. Sessions are initiated via a hanging GET request, which streams
|
||||
// server->client messages as SSE 'message' events.
|
||||
// 2. The first event in the SSE stream must be an 'endpoint' event that
|
||||
// informs the client of the session endpoint.
|
||||
// 3. The client POSTs client->server messages to the session endpoint.
|
||||
//
|
||||
// Therefore, the each new GET request hands off its responsewriter to an
|
||||
// [SSEServerTransport] type that abstracts the transport as follows:
|
||||
// - Write writes a new event to the responseWriter, or fails if the GET has
|
||||
// exited.
|
||||
// - Read reads off a message queue that is pushed to via POST requests.
|
||||
// - Close causes the hanging GET to exit.
|
||||
|
||||
// SSEHandler is an http.Handler that serves SSE-based MCP sessions as defined by
|
||||
// the [2024-11-05 version] of the MCP spec.
|
||||
//
|
||||
// [2024-11-05 version]: https://modelcontextprotocol.io/specification/2024-11-05/basic/transports
|
||||
type SSEHandler struct {
|
||||
getServer func(request *http.Request) *Server
|
||||
opts SSEOptions
|
||||
onConnection func(*ServerSession) // for testing; must not block
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*SSEServerTransport
|
||||
}
|
||||
|
||||
// SSEOptions specifies options for an [SSEHandler].
|
||||
// for now, it is empty, but may be extended in future.
|
||||
// https://github.com/modelcontextprotocol/go-sdk/issues/507
|
||||
type SSEOptions struct{}
|
||||
|
||||
// NewSSEHandler returns a new [SSEHandler] that creates and manages MCP
|
||||
// sessions created via incoming HTTP requests.
|
||||
//
|
||||
// Sessions are created when the client issues a GET request to the server,
|
||||
// which must accept text/event-stream responses (server-sent events).
|
||||
// For each such request, a new [SSEServerTransport] is created with a distinct
|
||||
// messages endpoint, and connected to the server returned by getServer.
|
||||
// The SSEHandler also handles requests to the message endpoints, by
|
||||
// delegating them to the relevant server transport.
|
||||
//
|
||||
// The getServer function may return a distinct [Server] for each new
|
||||
// request, or reuse an existing server. If it returns nil, the handler
|
||||
// will return a 400 Bad Request.
|
||||
func NewSSEHandler(getServer func(request *http.Request) *Server, opts *SSEOptions) *SSEHandler {
|
||||
s := &SSEHandler{
|
||||
getServer: getServer,
|
||||
sessions: make(map[string]*SSEServerTransport),
|
||||
}
|
||||
|
||||
if opts != nil {
|
||||
s.opts = *opts
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// A SSEServerTransport is a logical SSE session created through a hanging GET
|
||||
// request.
|
||||
//
|
||||
// Use [SSEServerTransport.Connect] to initiate the flow of messages.
|
||||
//
|
||||
// When connected, it returns the following [Connection] implementation:
|
||||
// - Writes are SSE 'message' events to the GET response.
|
||||
// - Reads are received from POSTs to the session endpoint, via
|
||||
// [SSEServerTransport.ServeHTTP].
|
||||
// - Close terminates the hanging GET.
|
||||
//
|
||||
// The transport is itself an [http.Handler]. It is the caller's responsibility
|
||||
// to ensure that the resulting transport serves HTTP requests on the given
|
||||
// session endpoint.
|
||||
//
|
||||
// Each SSEServerTransport may be connected (via [Server.Connect]) at most
|
||||
// once, since [SSEServerTransport.ServeHTTP] serves messages to the connected
|
||||
// session.
|
||||
//
|
||||
// Most callers should instead use an [SSEHandler], which transparently handles
|
||||
// the delegation to SSEServerTransports.
|
||||
type SSEServerTransport struct {
|
||||
// Endpoint is the endpoint for this session, where the client can POST
|
||||
// messages.
|
||||
Endpoint string
|
||||
|
||||
// Response is the hanging response body to the incoming GET request.
|
||||
Response http.ResponseWriter
|
||||
|
||||
// incoming is the queue of incoming messages.
|
||||
// It is never closed, and by convention, incoming is non-nil if and only if
|
||||
// the transport is connected.
|
||||
incoming chan jsonrpc.Message
|
||||
|
||||
// We must guard both pushes to the incoming queue and writes to the response
|
||||
// writer, because incoming POST requests are arbitrarily concurrent and we
|
||||
// need to ensure we don't write push to the queue, or write to the
|
||||
// ResponseWriter, after the session GET request exits.
|
||||
mu sync.Mutex // also guards writes to Response
|
||||
closed bool // set when the stream is closed
|
||||
done chan struct{} // closed when the connection is closed
|
||||
}
|
||||
|
||||
// ServeHTTP handles POST requests to the transport endpoint.
|
||||
func (t *SSEServerTransport) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
if t.incoming == nil {
|
||||
http.Error(w, "session not connected", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Read and parse the message.
|
||||
data, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Optionally, we could just push the data onto a channel, and let the
|
||||
// message fail to parse when it is read. This failure seems a bit more
|
||||
// useful
|
||||
msg, err := jsonrpc2.DecodeMessage(data)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to parse body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req, ok := msg.(*jsonrpc.Request); ok {
|
||||
if _, err := checkRequest(req, serverMethodInfos); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
select {
|
||||
case t.incoming <- msg:
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
case <-t.done:
|
||||
http.Error(w, "session closed", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect sends the 'endpoint' event to the client.
|
||||
// See [SSEServerTransport] for more details on the [Connection] implementation.
|
||||
func (t *SSEServerTransport) Connect(context.Context) (Connection, error) {
|
||||
if t.incoming != nil {
|
||||
return nil, fmt.Errorf("already connected")
|
||||
}
|
||||
t.incoming = make(chan jsonrpc.Message, 100)
|
||||
t.done = make(chan struct{})
|
||||
_, err := writeEvent(t.Response, Event{
|
||||
Name: "endpoint",
|
||||
Data: []byte(t.Endpoint),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sseServerConn{t: t}, nil
|
||||
}
|
||||
|
||||
func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
sessionID := req.URL.Query().Get("sessionid")
|
||||
|
||||
// TODO: consider checking Content-Type here. For now, we are lax.
|
||||
|
||||
// For POST requests, the message body is a message to send to a session.
|
||||
if req.Method == http.MethodPost {
|
||||
// Look up the session.
|
||||
if sessionID == "" {
|
||||
http.Error(w, "sessionid must be provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
session := h.sessions[sessionID]
|
||||
h.mu.Unlock()
|
||||
if session == nil {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
session.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// GET requests create a new session, and serve messages over SSE.
|
||||
|
||||
// TODO: it's not entirely documented whether we should check Accept here.
|
||||
// Let's again be lax and assume the client will accept SSE.
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
sessionID = rand.Text()
|
||||
endpoint, err := req.URL.Parse("?sessionid=" + sessionID)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error: failed to create endpoint", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
transport := &SSEServerTransport{Endpoint: endpoint.RequestURI(), Response: w}
|
||||
|
||||
// The session is terminated when the request exits.
|
||||
h.mu.Lock()
|
||||
h.sessions[sessionID] = transport
|
||||
h.mu.Unlock()
|
||||
defer func() {
|
||||
h.mu.Lock()
|
||||
delete(h.sessions, sessionID)
|
||||
h.mu.Unlock()
|
||||
}()
|
||||
|
||||
server := h.getServer(req)
|
||||
if server == nil {
|
||||
// The getServer argument to NewSSEHandler returned nil.
|
||||
http.Error(w, "no server available", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ss, err := server.Connect(req.Context(), transport, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "connection failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if h.onConnection != nil {
|
||||
h.onConnection(ss)
|
||||
}
|
||||
defer ss.Close() // close the transport when the GET exits
|
||||
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
case <-transport.done:
|
||||
}
|
||||
}
|
||||
|
||||
// sseServerConn implements the [Connection] interface for a single [SSEServerTransport].
|
||||
// It hides the Connection interface from the SSEServerTransport API.
|
||||
type sseServerConn struct {
|
||||
t *SSEServerTransport
|
||||
}
|
||||
|
||||
// TODO(jba): get the session ID. (Not urgent because SSE transports have been removed from the spec.)
|
||||
func (s *sseServerConn) SessionID() string { return "" }
|
||||
|
||||
// Read implements jsonrpc2.Reader.
|
||||
func (s *sseServerConn) Read(ctx context.Context) (jsonrpc.Message, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case msg := <-s.t.incoming:
|
||||
return msg, nil
|
||||
case <-s.t.done:
|
||||
return nil, io.EOF
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements jsonrpc2.Writer.
|
||||
func (s *sseServerConn) Write(ctx context.Context, msg jsonrpc.Message) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
data, err := jsonrpc2.EncodeMessage(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.t.mu.Lock()
|
||||
defer s.t.mu.Unlock()
|
||||
|
||||
// Note that it is invalid to write to a ResponseWriter after ServeHTTP has
|
||||
// exited, and so we must lock around this write and check isDone, which is
|
||||
// set before the hanging GET exits.
|
||||
if s.t.closed {
|
||||
return io.EOF
|
||||
}
|
||||
|
||||
_, err = writeEvent(s.t.Response, Event{Name: "message", Data: data})
|
||||
return err
|
||||
}
|
||||
|
||||
// Close implements io.Closer, and closes the session.
|
||||
//
|
||||
// It must be safe to call Close more than once, as the close may
|
||||
// asynchronously be initiated by either the server closing its connection, or
|
||||
// by the hanging GET exiting.
|
||||
func (s *sseServerConn) Close() error {
|
||||
s.t.mu.Lock()
|
||||
defer s.t.mu.Unlock()
|
||||
if !s.t.closed {
|
||||
s.t.closed = true
|
||||
close(s.t.done)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// An SSEClientTransport is a [Transport] that can communicate with an MCP
|
||||
// endpoint serving the SSE transport defined by the 2024-11-05 version of the
|
||||
// spec.
|
||||
//
|
||||
// https://modelcontextprotocol.io/specification/2024-11-05/basic/transports
|
||||
type SSEClientTransport struct {
|
||||
// Endpoint is the SSE endpoint to connect to.
|
||||
Endpoint string
|
||||
|
||||
// HTTPClient is the client to use for making HTTP requests. If nil,
|
||||
// http.DefaultClient is used.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Connect connects through the client endpoint.
|
||||
func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) {
|
||||
parsedURL, err := url.Parse(c.Endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid endpoint: %v", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.Endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := c.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check HTTP status code before attempting to parse SSE events.
|
||||
// This ensures proper error reporting for authentication failures (401),
|
||||
// authorization failures (403), and other HTTP errors.
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("failed to connect: %s", http.StatusText(resp.StatusCode))
|
||||
}
|
||||
|
||||
msgEndpoint, err := func() (*url.URL, error) {
|
||||
var evt Event
|
||||
for evt, err = range scanEvents(resp.Body) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if evt.Name != "endpoint" {
|
||||
return nil, fmt.Errorf("first event is %q, want %q", evt.Name, "endpoint")
|
||||
}
|
||||
raw := string(evt.Data)
|
||||
return parsedURL.Parse(raw)
|
||||
}()
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("missing endpoint: %v", err)
|
||||
}
|
||||
|
||||
// From here on, the stream takes ownership of resp.Body.
|
||||
s := &sseClientConn{
|
||||
client: httpClient,
|
||||
msgEndpoint: msgEndpoint,
|
||||
incoming: make(chan []byte, 100),
|
||||
body: resp.Body,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer s.Close() // close the transport when the GET exits
|
||||
|
||||
for evt, err := range scanEvents(resp.Body) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case s.incoming <- evt.Data:
|
||||
case <-s.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// An sseClientConn is a logical jsonrpc2 connection that implements the client
|
||||
// half of the SSE protocol:
|
||||
// - Writes are POSTS to the session endpoint.
|
||||
// - Reads are SSE 'message' events, and pushes them onto a buffered channel.
|
||||
// - Close terminates the GET request.
|
||||
type sseClientConn struct {
|
||||
client *http.Client // HTTP client to use for requests
|
||||
msgEndpoint *url.URL // session endpoint for POSTs
|
||||
incoming chan []byte // queue of incoming messages
|
||||
|
||||
mu sync.Mutex
|
||||
body io.ReadCloser // body of the hanging GET
|
||||
closed bool // set when the stream is closed
|
||||
done chan struct{} // closed when the stream is closed
|
||||
}
|
||||
|
||||
// TODO(jba): get the session ID. (Not urgent because SSE transports have been removed from the spec.)
|
||||
func (c *sseClientConn) SessionID() string { return "" }
|
||||
|
||||
func (c *sseClientConn) isDone() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.closed
|
||||
}
|
||||
|
||||
func (c *sseClientConn) Read(ctx context.Context) (jsonrpc.Message, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
|
||||
case <-c.done:
|
||||
return nil, io.EOF
|
||||
|
||||
case data := <-c.incoming:
|
||||
// TODO(rfindley): do we really need to check this? We receive from c.done above.
|
||||
if c.isDone() {
|
||||
return nil, io.EOF
|
||||
}
|
||||
msg, err := jsonrpc2.DecodeMessage(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *sseClientConn) Write(ctx context.Context, msg jsonrpc.Message) error {
|
||||
data, err := jsonrpc2.EncodeMessage(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.isDone() {
|
||||
return io.EOF
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.msgEndpoint.String(), bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("failed to write: %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *sseClientConn) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if !c.closed {
|
||||
c.closed = true
|
||||
_ = c.body.Close()
|
||||
close(c.done)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+2221
File diff suppressed because it is too large
Load Diff
+226
@@ -0,0 +1,226 @@
|
||||
// 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.
|
||||
|
||||
// TODO: move client-side streamable HTTP logic from streamable.go to this file.
|
||||
|
||||
package mcp
|
||||
|
||||
/*
|
||||
Streamable HTTP Client Design
|
||||
|
||||
This document describes the client-side implementation of the MCP streamable
|
||||
HTTP transport, as defined by the MCP spec:
|
||||
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http
|
||||
|
||||
# Overview
|
||||
|
||||
The client-side streamable transport allows an MCP client to communicate with a
|
||||
server over HTTP, sending messages via POST and receiving responses via either
|
||||
JSON or server-sent events (SSE). The implementation consists of two main
|
||||
components:
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [StreamableClientTransport] │
|
||||
│ Transport configuration; creates connections via Connect() │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [streamableClientConn] │
|
||||
│ Connection implementation; handles HTTP request/response │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
├──────────────────────────────────────┐
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────┐ ┌────────────────────────────────────┐
|
||||
│ POST request handlers │ │ Standalone SSE stream │
|
||||
│ (one per outgoing message/call) │ │ (server-initiated messages) │
|
||||
└─────────────────────────────────────────┘ └────────────────────────────────────┘
|
||||
|
||||
# Sessions
|
||||
|
||||
The client maintains a session with the server, identified by a session ID
|
||||
(Mcp-Session-Id header):
|
||||
|
||||
- Session ID is received from the server after initialization
|
||||
- Client includes the session ID in all subsequent requests
|
||||
- Session ends when the client calls Close() (sends DELETE) or server returns 404
|
||||
|
||||
[streamableClientConn] stores the session state:
|
||||
- [streamableClientConn.sessionID]: Server-assigned session identifier
|
||||
- [streamableClientConn.initializedResult]: Protocol version and server capabilities
|
||||
|
||||
# Connection Lifecycle
|
||||
|
||||
1. Connect: [StreamableClientTransport.Connect] creates a [streamableClientConn]
|
||||
with a detached context for the connection's lifetime. The context is detached
|
||||
to prevent the standalone SSE stream from being cancelled when the original
|
||||
Connect context times out.
|
||||
|
||||
2. Initialize: The MCP client sends initialize/initialized messages. Upon
|
||||
receiving [InitializeResult], the connection:
|
||||
- Stores the negotiated protocol version for the Mcp-Protocol-Version header
|
||||
- Captures the session ID from the Mcp-Session-Id response header
|
||||
- Starts the standalone SSE stream via [streamableClientConn.connectStandaloneSSE]
|
||||
|
||||
3. Operation: Messages are sent via POST, responses received via JSON or SSE.
|
||||
|
||||
4. Close: [streamableClientConn.Close] sends a DELETE request to terminate
|
||||
the session (unless the session is already gone), then cancels the connection
|
||||
context to clean up the standalone SSE stream.
|
||||
|
||||
# Sending Messages (Write)
|
||||
|
||||
[streamableClientConn.Write] sends all outgoing messages via HTTP POST:
|
||||
|
||||
POST /endpoint
|
||||
Content-Type: application/json
|
||||
Accept: application/json, text/event-stream
|
||||
Mcp-Protocol-Version: <negotiated version>
|
||||
Mcp-Session-Id: <session ID, if established>
|
||||
|
||||
<JSON-RPC message>
|
||||
|
||||
The server may respond with:
|
||||
- 202 Accepted: Message received, no response body (notifications/responses)
|
||||
- 200 OK with application/json: Single JSON-RPC response
|
||||
- 200 OK with text/event-stream: SSE stream of responses
|
||||
|
||||
# Receiving Messages (Read)
|
||||
|
||||
[streamableClientConn.Read] returns messages from the [streamableClientConn.incoming]
|
||||
channel, which is populated by multiple concurrent goroutines:
|
||||
|
||||
1. POST response handlers ([streamableClientConn.handleJSON] and
|
||||
[streamableClientConn.handleSSE]): Process responses from POST requests
|
||||
|
||||
2. Standalone SSE stream: Receives server-initiated requests and notifications
|
||||
|
||||
The client handles both response formats:
|
||||
- JSON: [streamableClientConn.handleJSON] reads body, decodes message
|
||||
- SSE: [streamableClientConn.handleSSE] scans events, decodes each message
|
||||
|
||||
# Standalone SSE Stream
|
||||
|
||||
After initialization, [streamableClientConn.sessionUpdated] triggers
|
||||
[streamableClientConn.connectStandaloneSSE] to open a GET request for
|
||||
server-initiated messages:
|
||||
|
||||
GET /endpoint
|
||||
Accept: text/event-stream
|
||||
Mcp-Session-Id: <session ID>
|
||||
|
||||
Stream behavior:
|
||||
- Optional: Server may return 405 Method Not Allowed (spec-compliant) or
|
||||
other 4xx errors (tolerated in non-strict mode for compatibility)
|
||||
- Persistent: Runs for the connection lifetime in a background goroutine
|
||||
- Resumable: Uses Last-Event-ID header on reconnection if server provides event IDs
|
||||
- Reconnects: Automatic reconnection with exponential backoff on interruption
|
||||
|
||||
# Stream Resumption
|
||||
|
||||
When an SSE stream (standalone or POST response) is interrupted, the client
|
||||
attempts to reconnect using [streamableClientConn.connectSSE]:
|
||||
|
||||
Event ID tracking:
|
||||
- [streamableClientConn.processStream] tracks the last received event ID
|
||||
- On reconnection, the Last-Event-ID header is set to resume from that point
|
||||
- Server replays missed events if it has an [EventStore] configured
|
||||
|
||||
See [calculateReconnectDelay] for the reconnect delay details.
|
||||
|
||||
Server-initiated reconnection (SEP-1699)
|
||||
- SSE retry field: Sets the delay for the next reconnect attempt
|
||||
- If server doesn't provide event IDs, non-standalone streams don't reconnect
|
||||
|
||||
# Response Formats
|
||||
|
||||
The client must handle two response formats from POST requests:
|
||||
|
||||
1. application/json: Single JSON-RPC response
|
||||
- Body contains one JSON-RPC message
|
||||
- Handled by [streamableClientConn.handleJSON]
|
||||
- Simpler but doesn't support streaming or server-initiated messages
|
||||
|
||||
2. text/event-stream: SSE stream of messages
|
||||
- Body contains SSE events with JSON-RPC messages
|
||||
- Handled by [streamableClientConn.handleSSE]
|
||||
- Supports multiple messages and server-initiated communication
|
||||
- Stream completes when the response to the originating call is received
|
||||
|
||||
# HTTP Methods
|
||||
|
||||
- POST: Send JSON-RPC messages (requests, responses, notifications)
|
||||
- Used by [streamableClientConn.Write]
|
||||
- Response may be JSON or SSE
|
||||
|
||||
- GET: Open or resume SSE stream for server-initiated messages
|
||||
- Used by [streamableClientConn.connectSSE]
|
||||
- Always expects text/event-stream response (or 405)
|
||||
|
||||
- DELETE: Terminate the session
|
||||
- Used by [streamableClientConn.Close]
|
||||
- Skipped if session is already known to be gone ([ErrSessionMissing])
|
||||
|
||||
# Error Handling
|
||||
|
||||
Errors are categorized and handled differently:
|
||||
|
||||
1. Transient (recoverable via reconnection):
|
||||
- Network interruption during SSE streaming
|
||||
- Connection reset or timeout
|
||||
- Triggers reconnection in [streamableClientConn.handleSSE]
|
||||
|
||||
2. Terminal (breaks the connection):
|
||||
- 404 Not Found: Session terminated by server ([ErrSessionMissing])
|
||||
- Message decode errors: Protocol violation
|
||||
- Context cancellation: Client closed connection
|
||||
- Mismatched session IDs: Protocol error
|
||||
- See issue #683: our terminal errors are too strict.
|
||||
|
||||
Terminal errors are stored via [streamableClientConn.fail] and returned by
|
||||
subsequent [streamableClientConn.Read] calls. The [streamableClientConn.failed]
|
||||
channel signals that the connection is broken.
|
||||
|
||||
Special case: [ErrSessionMissing] indicates the server has terminated the session,
|
||||
so [streamableClientConn.Close] skips the DELETE request.
|
||||
|
||||
# Protocol Version Header
|
||||
|
||||
After initialization, all requests include:
|
||||
|
||||
Mcp-Protocol-Version: <negotiated version>
|
||||
|
||||
This header (set by [streamableClientConn.setMCPHeaders]):
|
||||
- Allows the server to handle requests per the negotiated protocol
|
||||
- Is omitted before initialization completes
|
||||
- Uses the version from [streamableClientConn.initializedResult]
|
||||
|
||||
# Key Implementation Details
|
||||
|
||||
[StreamableClientTransport] configuration:
|
||||
- [StreamableClientTransport.Endpoint]: URL of the MCP server
|
||||
- [StreamableClientTransport.HTTPClient]: Custom HTTP client (optional)
|
||||
- [StreamableClientTransport.MaxRetries]: Reconnection attempts (default 5)
|
||||
|
||||
[streamableClientConn] handles the [Connection] interface:
|
||||
- [streamableClientConn.Read]: Returns messages from incoming channel
|
||||
- [streamableClientConn.Write]: Sends messages via POST, starts response handlers
|
||||
- [streamableClientConn.Close]: Sends DELETE, cancels context, closes done channel
|
||||
|
||||
State management:
|
||||
- [streamableClientConn.incoming]: Buffered channel for received messages
|
||||
- [streamableClientConn.sessionID]: Server-assigned session identifier
|
||||
- [streamableClientConn.initializedResult]: Cached for protocol version header
|
||||
- [streamableClientConn.failed]: Channel closed on terminal error
|
||||
- [streamableClientConn.done]: Channel closed on graceful shutdown
|
||||
- [streamableClientConn.ctx]: Detached context for connection lifetime
|
||||
- [streamableClientConn.cancel]: Cancels ctx to terminate SSE streams
|
||||
|
||||
Context handling:
|
||||
- Connection context is detached from [StreamableClientTransport.Connect] context
|
||||
using [xcontext.Detach] to preserve context values (for auth middleware) while
|
||||
preventing premature cancellation of the standalone SSE stream
|
||||
- Individual POST requests use caller-provided contexts for cancellation
|
||||
*/
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
// 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.
|
||||
|
||||
// TODO: move server-side streamable HTTP logic from streamable.go to this file.
|
||||
|
||||
package mcp
|
||||
|
||||
/*
|
||||
Streamable HTTP Server Design
|
||||
|
||||
This document describes the server-side implementation of the MCP streamable
|
||||
HTTP transport, as defined by the MCP spec:
|
||||
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http
|
||||
|
||||
# Overview
|
||||
|
||||
The streamable HTTP transport enables MCP communication over HTTP, with
|
||||
server-sent events (SSE) for server-to-client messages. The implementation
|
||||
consists of several layered components:
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [StreamableHTTPHandler] │
|
||||
│ http.Handler that manages sessions and routes HTTP requests │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [StreamableServerTransport] │
|
||||
│ transport implementation, one per session; exposes ServeHTTP │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [streamableServerConn] │
|
||||
│ Connection implementation, handles message routing │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [stream] │
|
||||
│ Logical message channel within a session, may be resumed │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
# Sessions
|
||||
|
||||
As with other transports, a session represents a logical MCP connection between
|
||||
a client and server. In the streamable transport, sessions are identified by a
|
||||
unique session ID (Mcp-Session-Id header) and persist across multiple HTTP
|
||||
requests.
|
||||
|
||||
[StreamableHTTPHandler] maintains a map of active sessions ([sessionInfo]),
|
||||
each containing:
|
||||
- The [ServerSession] (MCP-level session state)
|
||||
- The [StreamableServerTransport] (for message I/O)
|
||||
- Optional timeout management for idle session cleanup
|
||||
|
||||
Sessions are created on the first POST request (typically containing the
|
||||
initialize request) and destroyed either by:
|
||||
- Client sending a DELETE request
|
||||
- Session timeout due to inactivity
|
||||
- Server explicitly closing the session
|
||||
|
||||
# Streams
|
||||
|
||||
Within a session, there can be multiple concurrent "streams" - logical channels
|
||||
for message delivery. This is distinct from HTTP streams; a single [stream] may
|
||||
span multiple HTTP request/response cycles (via resumption).
|
||||
|
||||
There are two types of streams:
|
||||
|
||||
1. Optional standalone SSE stream (id = ""):
|
||||
- Created when client sends a GET request to the endpoint
|
||||
- Used for server-initiated messages (requests/notifications to client)
|
||||
- Persists for the lifetime of the session
|
||||
- Only one standalone stream per session
|
||||
|
||||
2. Request streams (id = random string):
|
||||
- Created for each POST request containing JSON-RPC calls
|
||||
- Used to route responses back to the originating HTTP request
|
||||
- Completed when all responses have been sent
|
||||
- Can be resumed via GET with Last-Event-ID if interrupted
|
||||
|
||||
# Message Routing
|
||||
|
||||
When the server writes a message, it must be routed to the correct [stream]:
|
||||
|
||||
- Responses: Routed to the stream that originated the request
|
||||
- Requests/Notifications made during request handling: Routed to the same
|
||||
stream as the triggering request (via context)
|
||||
- Requests/Notifications made outside request handling: Routed to the
|
||||
standalone SSE stream
|
||||
|
||||
This routing is implemented using:
|
||||
- [streamableServerConn.requestStreams] maps request IDs to stream IDs
|
||||
- [idContextKey] is used to store the originating request ID in Context
|
||||
- [streamableServerConn.streams] maps stream IDs to [stream] objects
|
||||
|
||||
# Stream Resumption
|
||||
|
||||
If an HTTP connection is interrupted (network issues, etc.), clients can
|
||||
resume a stream by sending a GET request with the Last-Event-ID header.
|
||||
This requires an [EventStore] to be configured on the server.
|
||||
|
||||
- [EventStore.Open] is called when a new stream is created
|
||||
- [EventStore.Append] is called for each message written to the stream
|
||||
- [EventStore.After] is called to replay messages after a given index
|
||||
- [EventStore.SessionClosed] is called when the session ends
|
||||
|
||||
Event IDs are formatted as "<streamID>_<index>" to identify both the
|
||||
stream and position within that stream (see [formatEventID] and [parseEventID]).
|
||||
|
||||
# Stateless Mode
|
||||
|
||||
For simpler deployments, the handler supports "stateless" mode
|
||||
([StreamableHTTPOptions.Stateless]) where:
|
||||
- No session ID validation is performed
|
||||
- Each request creates a temporary session that's closed after the request
|
||||
- Server-to-client requests are not supported (no way to receive response)
|
||||
|
||||
This mode is useful for simple tool servers that don't need bidirectional
|
||||
communication.
|
||||
|
||||
# Response Formats
|
||||
|
||||
The server can respond to POST requests in two formats:
|
||||
|
||||
1. text/event-stream (default): Messages sent as SSE events, supports
|
||||
streaming multiple messages and server-initiated communication during
|
||||
request handling.
|
||||
|
||||
2. application/json ([StreamableHTTPOptions.JSONResponse]): Single JSON
|
||||
response, simpler but doesn't support streaming. Server-initiated messages
|
||||
during request handling go to the standalone SSE stream instead.
|
||||
|
||||
# HTTP Methods
|
||||
|
||||
- POST: Send JSON-RPC messages (requests, responses, notifications)
|
||||
- GET: Open standalone SSE stream or resume an interrupted stream
|
||||
- DELETE: Terminate the session
|
||||
|
||||
# Key Implementation Details
|
||||
|
||||
The [stream] struct manages delivery of messages to HTTP responses.
|
||||
|
||||
Fields:
|
||||
- [stream.w] is the ResponseWriter for the current HTTP response (non-nil indicates claimed)
|
||||
- [stream.done] is closed to release the hanging HTTP request
|
||||
- [stream.requests] tracks pending request IDs (stream completes when empty)
|
||||
|
||||
Methods:
|
||||
- [stream.deliverLocked] delivers a message to the stream
|
||||
- [stream.close] sends a close event and releases the stream
|
||||
- [stream.release] releases the stream from the HTTP request, allowing resumption
|
||||
|
||||
[streamableServerConn] handles the [Connection] interface:
|
||||
- [streamableServerConn.Read] receives messages from the incoming channel (fed by POST handlers)
|
||||
- [streamableServerConn.Write] routes messages to appropriate streams
|
||||
- [streamableServerConn.Close] terminates the session and notifies the [EventStore]
|
||||
*/
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
// 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"
|
||||
"strings"
|
||||
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
)
|
||||
|
||||
// A ToolHandler handles a call to tools/call.
|
||||
//
|
||||
// This is a low-level API, for use with [Server.AddTool]. It does not do any
|
||||
// pre- or post-processing of the request or result: the params contain raw
|
||||
// arguments, no input validation is performed, and the result is returned to
|
||||
// the user as-is, without any validation of the output.
|
||||
//
|
||||
// Most users will write a [ToolHandlerFor] and install it with the generic
|
||||
// [AddTool] function.
|
||||
//
|
||||
// If ToolHandler returns an error, it is treated as a protocol error. By
|
||||
// contrast, [ToolHandlerFor] automatically populates [CallToolResult.IsError]
|
||||
// and [CallToolResult.Content] accordingly.
|
||||
type ToolHandler func(context.Context, *CallToolRequest) (*CallToolResult, error)
|
||||
|
||||
// A ToolHandlerFor handles a call to tools/call with typed arguments and results.
|
||||
//
|
||||
// Use [AddTool] to add a ToolHandlerFor to a server.
|
||||
//
|
||||
// Unlike [ToolHandler], [ToolHandlerFor] provides significant functionality
|
||||
// out of the box, and enforces that the tool conforms to the MCP spec:
|
||||
// - The In type provides a default input schema for the tool, though it may
|
||||
// be overridden in [AddTool].
|
||||
// - The input value is automatically unmarshaled from req.Params.Arguments.
|
||||
// - The input value is automatically validated against its input schema.
|
||||
// Invalid input is rejected before getting to the handler.
|
||||
// - If the Out type is not the empty interface [any], it provides the
|
||||
// default output schema for the tool (which again may be overridden in
|
||||
// [AddTool]).
|
||||
// - The Out value is used to populate result.StructuredOutput.
|
||||
// - If [CallToolResult.Content] is unset, it is populated with the JSON
|
||||
// content of the output.
|
||||
// - An error result is treated as a tool error, rather than a protocol
|
||||
// error, and is therefore packed into CallToolResult.Content, with
|
||||
// [IsError] set.
|
||||
//
|
||||
// For these reasons, most users can ignore the [CallToolRequest] argument and
|
||||
// [CallToolResult] return values entirely. In fact, it is permissible to
|
||||
// return a nil CallToolResult, if you only care about returning a output value
|
||||
// or error. The effective result will be populated as described above.
|
||||
type ToolHandlerFor[In, Out any] func(_ context.Context, request *CallToolRequest, input In) (result *CallToolResult, output Out, _ error)
|
||||
|
||||
// A serverTool is a tool definition that is bound to a tool handler.
|
||||
type serverTool struct {
|
||||
tool *Tool
|
||||
handler ToolHandler
|
||||
}
|
||||
|
||||
// applySchema validates whether data is valid JSON according to the provided
|
||||
// schema, after applying schema defaults.
|
||||
//
|
||||
// Returns the JSON value augmented with defaults.
|
||||
func applySchema(data json.RawMessage, resolved *jsonschema.Resolved) (json.RawMessage, error) {
|
||||
// TODO: use reflection to create the struct type to unmarshal into.
|
||||
// Separate validation from assignment.
|
||||
|
||||
// Use default JSON marshalling for validation.
|
||||
//
|
||||
// This avoids inconsistent representation due to custom marshallers, such as
|
||||
// time.Time (issue #449).
|
||||
//
|
||||
// Additionally, unmarshalling into a map ensures that the resulting JSON is
|
||||
// at least {}, even if data is empty. For example, arguments is technically
|
||||
// an optional property of callToolParams, and we still want to apply the
|
||||
// defaults in this case.
|
||||
//
|
||||
// TODO(rfindley): in which cases can resolved be nil?
|
||||
if resolved != nil {
|
||||
v := make(map[string]any)
|
||||
if len(data) > 0 {
|
||||
if err := internaljson.Unmarshal(data, &v); err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling arguments: %w", err)
|
||||
}
|
||||
}
|
||||
if err := resolved.ApplyDefaults(&v); err != nil {
|
||||
return nil, fmt.Errorf("applying schema defaults:\n%w", err)
|
||||
}
|
||||
if err := resolved.Validate(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// We must re-marshal with the default values applied.
|
||||
var err error
|
||||
data, err = json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshalling with defaults: %v", err)
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// validateToolName checks whether name is a valid tool name, reporting a
|
||||
// non-nil error if not.
|
||||
func validateToolName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("tool name cannot be empty")
|
||||
}
|
||||
if len(name) > 128 {
|
||||
return fmt.Errorf("tool name exceeds maximum length of 128 characters (current: %d)", len(name))
|
||||
}
|
||||
// For consistency with other SDKs, report characters in the order the appear
|
||||
// in the name.
|
||||
var invalidChars []string
|
||||
seen := make(map[rune]bool)
|
||||
for _, r := range name {
|
||||
if !validToolNameRune(r) {
|
||||
if !seen[r] {
|
||||
invalidChars = append(invalidChars, fmt.Sprintf("%q", string(r)))
|
||||
seen[r] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(invalidChars) > 0 {
|
||||
return fmt.Errorf("tool name contains invalid characters: %s", strings.Join(invalidChars, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validToolNameRune reports whether r is valid within tool names.
|
||||
func validToolNameRune(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') ||
|
||||
(r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') ||
|
||||
r == '_' || r == '-' || r == '.'
|
||||
}
|
||||
+660
@@ -0,0 +1,660 @@
|
||||
// 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"
|
||||
"sync"
|
||||
|
||||
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/xcontext"
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
)
|
||||
|
||||
// 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")
|
||||
|
||||
// ErrSessionMissing is returned when the session is known to not be present on
|
||||
// the server.
|
||||
var ErrSessionMissing = errors.New("session not found")
|
||||
|
||||
// A Transport is used to create a bidirectional connection between MCP client
|
||||
// and server.
|
||||
//
|
||||
// Transports should be used for at most one call to [Server.Connect] or
|
||||
// [Client.Connect].
|
||||
type Transport interface {
|
||||
// Connect returns the logical JSON-RPC connection..
|
||||
//
|
||||
// It is called exactly once by [Server.Connect] or [Client.Connect].
|
||||
Connect(ctx context.Context) (Connection, error)
|
||||
}
|
||||
|
||||
// A Connection is a logical bidirectional JSON-RPC connection.
|
||||
type Connection interface {
|
||||
// Read reads the next message to process off the connection.
|
||||
//
|
||||
// Connections must allow Read to be called concurrently with Close. In
|
||||
// particular, calling Close should unblock a Read waiting for input.
|
||||
Read(context.Context) (jsonrpc.Message, error)
|
||||
|
||||
// Write writes a new message to the connection.
|
||||
//
|
||||
// Write may be called concurrently, as calls or responses may occur
|
||||
// concurrently in user code.
|
||||
Write(context.Context, jsonrpc.Message) error
|
||||
|
||||
// Close closes the connection. It is implicitly called whenever a Read or
|
||||
// Write fails.
|
||||
//
|
||||
// Close may be called multiple times, potentially concurrently.
|
||||
Close() error
|
||||
|
||||
// TODO(#148): remove SessionID from this interface.
|
||||
SessionID() string
|
||||
}
|
||||
|
||||
// A ClientConnection is a [Connection] that is specific to the MCP client.
|
||||
//
|
||||
// If client connections implement this interface, they may receive information
|
||||
// about changes to the client session.
|
||||
//
|
||||
// TODO: should this interface be exported?
|
||||
type clientConnection interface {
|
||||
Connection
|
||||
|
||||
// sessionUpdated is called whenever the client session state changes.
|
||||
sessionUpdated(clientSessionState)
|
||||
}
|
||||
|
||||
// A serverConnection is a Connection that is specific to the MCP server.
|
||||
//
|
||||
// If server connections implement this interface, they receive information
|
||||
// about changes to the server session.
|
||||
//
|
||||
// TODO: should this interface be exported?
|
||||
type serverConnection interface {
|
||||
Connection
|
||||
sessionUpdated(ServerSessionState)
|
||||
}
|
||||
|
||||
// A StdioTransport is a [Transport] that communicates over stdin/stdout using
|
||||
// newline-delimited JSON.
|
||||
type StdioTransport struct{}
|
||||
|
||||
// Connect implements the [Transport] interface.
|
||||
func (*StdioTransport) Connect(context.Context) (Connection, error) {
|
||||
return newIOConn(rwc{os.Stdin, nopCloserWriter{os.Stdout}}), nil
|
||||
}
|
||||
|
||||
// nopCloserWriter is an io.WriteCloser with a trivial Close method.
|
||||
type nopCloserWriter struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (nopCloserWriter) Close() error { return nil }
|
||||
|
||||
// An IOTransport is a [Transport] that communicates over separate
|
||||
// io.ReadCloser and io.WriteCloser using newline-delimited JSON.
|
||||
type IOTransport struct {
|
||||
Reader io.ReadCloser
|
||||
Writer io.WriteCloser
|
||||
}
|
||||
|
||||
// Connect implements the [Transport] interface.
|
||||
func (t *IOTransport) Connect(context.Context) (Connection, error) {
|
||||
return newIOConn(rwc{t.Reader, t.Writer}), nil
|
||||
}
|
||||
|
||||
// An InMemoryTransport is a [Transport] that communicates over an in-memory
|
||||
// network connection, using newline-delimited JSON.
|
||||
//
|
||||
// InMemoryTransports should be constructed using [NewInMemoryTransports],
|
||||
// which returns two transports connected to each other.
|
||||
type InMemoryTransport struct {
|
||||
rwc io.ReadWriteCloser
|
||||
}
|
||||
|
||||
// Connect implements the [Transport] interface.
|
||||
func (t *InMemoryTransport) Connect(context.Context) (Connection, error) {
|
||||
return newIOConn(t.rwc), nil
|
||||
}
|
||||
|
||||
// NewInMemoryTransports returns two [InMemoryTransport] objects that connect
|
||||
// to each other.
|
||||
//
|
||||
// The resulting transports are symmetrical: use either to connect to a server,
|
||||
// and then the other to connect to a client. Servers must be connected before
|
||||
// clients, as the client initializes the MCP session during connection.
|
||||
func NewInMemoryTransports() (*InMemoryTransport, *InMemoryTransport) {
|
||||
c1, c2 := net.Pipe()
|
||||
return &InMemoryTransport{c1}, &InMemoryTransport{c2}
|
||||
}
|
||||
|
||||
type binder[T handler, State any] interface {
|
||||
// TODO(rfindley): the bind API has gotten too complicated. Simplify.
|
||||
bind(Connection, *jsonrpc2.Connection, State, func()) T
|
||||
disconnect(T)
|
||||
}
|
||||
|
||||
type handler interface {
|
||||
handle(ctx context.Context, req *jsonrpc.Request) (any, error)
|
||||
}
|
||||
|
||||
func connect[H handler, State any](ctx context.Context, t Transport, b binder[H, State], s State, onClose func()) (H, error) {
|
||||
var zero H
|
||||
mcpConn, err := t.Connect(ctx)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
// If logging is configured, write message logs.
|
||||
reader, writer := jsonrpc2.Reader(mcpConn), jsonrpc2.Writer(mcpConn)
|
||||
var (
|
||||
h H
|
||||
preempter canceller
|
||||
)
|
||||
bind := func(conn *jsonrpc2.Connection) jsonrpc2.Handler {
|
||||
h = b.bind(mcpConn, conn, s, onClose)
|
||||
preempter.conn = conn
|
||||
return jsonrpc2.HandlerFunc(h.handle)
|
||||
}
|
||||
_ = jsonrpc2.NewConnection(ctx, jsonrpc2.ConnectionConfig{
|
||||
Reader: reader,
|
||||
Writer: writer,
|
||||
Closer: mcpConn,
|
||||
Bind: bind,
|
||||
Preempter: &preempter,
|
||||
OnDone: func() {
|
||||
b.disconnect(h)
|
||||
},
|
||||
OnInternalError: func(err error) { log.Printf("jsonrpc2 error: %v", err) },
|
||||
})
|
||||
assert(preempter.conn != nil, "unbound preempter")
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// A canceller is a jsonrpc2.Preempter that cancels in-flight requests on MCP
|
||||
// cancelled notifications.
|
||||
type canceller struct {
|
||||
conn *jsonrpc2.Connection
|
||||
}
|
||||
|
||||
// Preempt implements [jsonrpc2.Preempter].
|
||||
func (c *canceller) Preempt(ctx context.Context, req *jsonrpc.Request) (result any, err error) {
|
||||
if req.Method == notificationCancelled {
|
||||
var params CancelledParams
|
||||
if err := internaljson.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := jsonrpc2.MakeID(params.RequestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go c.conn.Cancel(id)
|
||||
}
|
||||
return nil, jsonrpc2.ErrNotHandled
|
||||
}
|
||||
|
||||
// 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 Params, result Result) error {
|
||||
// The "%w"s in this function expose jsonrpc.Error as part of the API.
|
||||
call := conn.Call(ctx, method, params)
|
||||
err := call.Await(ctx, result)
|
||||
switch {
|
||||
case errors.Is(err, jsonrpc2.ErrClientClosing), errors.Is(err, jsonrpc2.ErrServerClosing):
|
||||
return fmt.Errorf("%w: calling %q: %v", ErrConnectionClosed, method, err)
|
||||
case ctx.Err() != nil:
|
||||
// Notify the peer of cancellation.
|
||||
err := conn.Notify(xcontext.Detach(ctx), notificationCancelled, &CancelledParams{
|
||||
Reason: ctx.Err().Error(),
|
||||
RequestID: call.ID().Raw(),
|
||||
})
|
||||
// By default, the jsonrpc2 library waits for graceful shutdown when the
|
||||
// connection is closed, meaning it expects all outgoing and incoming
|
||||
// requests to complete. However, for MCP this expectation is unrealistic,
|
||||
// and can lead to hanging shutdown. For example, if a streamable client is
|
||||
// killed, the server will not be able to detect this event, except via
|
||||
// keepalive pings (if they are configured), and so outgoing calls may hang
|
||||
// indefinitely.
|
||||
//
|
||||
// Therefore, we choose to eagerly retire calls, removing them from the
|
||||
// outgoingCalls map, when the caller context is cancelled: if the caller
|
||||
// will never receive the response, there's no need to track it.
|
||||
conn.Retire(call, ctx.Err())
|
||||
return errors.Join(ctx.Err(), err)
|
||||
case err != nil:
|
||||
return fmt.Errorf("calling %q: %w", method, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// A LoggingTransport is a [Transport] that delegates to another transport,
|
||||
// writing RPC logs to an io.Writer.
|
||||
type LoggingTransport struct {
|
||||
Transport Transport
|
||||
Writer io.Writer
|
||||
}
|
||||
|
||||
// Connect connects the underlying transport, returning a [Connection] that writes
|
||||
// logs to the configured destination.
|
||||
func (t *LoggingTransport) Connect(ctx context.Context) (Connection, error) {
|
||||
delegate, err := t.Transport.Connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &loggingConn{delegate: delegate, w: t.Writer}, nil
|
||||
}
|
||||
|
||||
type loggingConn struct {
|
||||
delegate Connection
|
||||
|
||||
mu sync.Mutex
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
func (c *loggingConn) SessionID() string { return c.delegate.SessionID() }
|
||||
|
||||
// Read is a stream middleware that logs incoming messages.
|
||||
func (s *loggingConn) Read(ctx context.Context) (jsonrpc.Message, error) {
|
||||
msg, err := s.delegate.Read(ctx)
|
||||
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
fmt.Fprintf(s.w, "read error: %v\n", err)
|
||||
s.mu.Unlock()
|
||||
} else {
|
||||
data, err := jsonrpc2.EncodeMessage(msg)
|
||||
s.mu.Lock()
|
||||
if err != nil {
|
||||
fmt.Fprintf(s.w, "LoggingTransport: failed to marshal: %v", err)
|
||||
}
|
||||
fmt.Fprintf(s.w, "read: %s\n", string(data))
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
return msg, err
|
||||
}
|
||||
|
||||
// Write is a stream middleware that logs outgoing messages.
|
||||
func (s *loggingConn) Write(ctx context.Context, msg jsonrpc.Message) error {
|
||||
err := s.delegate.Write(ctx, msg)
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
fmt.Fprintf(s.w, "write error: %v\n", err)
|
||||
s.mu.Unlock()
|
||||
} else {
|
||||
data, err := jsonrpc2.EncodeMessage(msg)
|
||||
s.mu.Lock()
|
||||
if err != nil {
|
||||
fmt.Fprintf(s.w, "LoggingTransport: failed to marshal: %v", err)
|
||||
}
|
||||
fmt.Fprintf(s.w, "write: %s\n", string(data))
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *loggingConn) Close() error {
|
||||
return s.delegate.Close()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return r.rc.Read(p)
|
||||
}
|
||||
|
||||
func (r rwc) Write(p []byte) (n int, err error) {
|
||||
return r.wc.Write(p)
|
||||
}
|
||||
|
||||
func (r rwc) Close() error {
|
||||
rcErr := r.rc.Close()
|
||||
|
||||
var wcErr error
|
||||
if r.wc != nil { // we only allow a nil writer in unit tests
|
||||
wcErr = r.wc.Close()
|
||||
}
|
||||
|
||||
return errors.Join(rcErr, wcErr)
|
||||
}
|
||||
|
||||
// An ioConn is a transport that delimits messages with newlines across
|
||||
// a bidirectional stream, and supports jsonrpc.2 message batching.
|
||||
//
|
||||
// See https://github.com/ndjson/ndjson-spec for discussion of newline
|
||||
// delimited JSON.
|
||||
//
|
||||
// See [msgBatch] for more discussion of message batching.
|
||||
type ioConn struct {
|
||||
protocolVersion string // negotiated version, set during session initialization.
|
||||
|
||||
writeMu sync.Mutex // guards Write, which must be concurrency safe.
|
||||
rwc io.ReadWriteCloser // the underlying stream
|
||||
|
||||
// incoming receives messages from the read loop started in [newIOConn].
|
||||
incoming <-chan msgOrErr
|
||||
|
||||
// If outgoiBatch has a positive capacity, it will be used to batch requests
|
||||
// and notifications before sending.
|
||||
outgoingBatch []jsonrpc.Message
|
||||
|
||||
// Unread messages in the last batch. Since reads are serialized, there is no
|
||||
// need to guard here.
|
||||
queue []jsonrpc.Message
|
||||
|
||||
// batches correlate incoming requests to the batch in which they arrived.
|
||||
// Since writes may be concurrent to reads, we need to guard this with a mutex.
|
||||
batchMu sync.Mutex
|
||||
batches map[jsonrpc2.ID]*msgBatch // lazily allocated
|
||||
|
||||
closeOnce sync.Once
|
||||
closed chan struct{}
|
||||
closeErr error
|
||||
}
|
||||
|
||||
type msgOrErr struct {
|
||||
msg json.RawMessage
|
||||
err error
|
||||
}
|
||||
|
||||
func newIOConn(rwc io.ReadWriteCloser) *ioConn {
|
||||
var (
|
||||
incoming = make(chan msgOrErr)
|
||||
closed = make(chan struct{})
|
||||
)
|
||||
// Start a goroutine for reads, so that we can select on the incoming channel
|
||||
// in [ioConn.Read] and unblock the read as soon as Close is called (see #224).
|
||||
//
|
||||
// This leaks a goroutine if rwc.Read does not unblock after it is closed,
|
||||
// but that is unavoidable since AFAIK there is no (easy and portable) way to
|
||||
// guarantee that reads of stdin are unblocked when closed.
|
||||
go func() {
|
||||
dec := json.NewDecoder(rwc)
|
||||
for {
|
||||
var raw json.RawMessage
|
||||
err := dec.Decode(&raw)
|
||||
// If decoding was successful, check for trailing data at the end of the stream.
|
||||
if err == nil {
|
||||
// Read the next byte to check if there is trailing data.
|
||||
var tr [1]byte
|
||||
if n, readErr := dec.Buffered().Read(tr[:]); n > 0 {
|
||||
// If read byte is not a newline, it is an error.
|
||||
// Support both Unix (\n) and Windows (\r\n) line endings.
|
||||
if tr[0] != '\n' && tr[0] != '\r' {
|
||||
err = fmt.Errorf("invalid trailing data at the end of stream")
|
||||
}
|
||||
} else if readErr != nil && readErr != io.EOF {
|
||||
err = readErr
|
||||
}
|
||||
}
|
||||
select {
|
||||
case incoming <- msgOrErr{msg: raw, err: err}:
|
||||
case <-closed:
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return &ioConn{
|
||||
rwc: rwc,
|
||||
incoming: incoming,
|
||||
closed: closed,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ioConn) SessionID() string { return "" }
|
||||
|
||||
func (c *ioConn) sessionUpdated(state ServerSessionState) {
|
||||
protocolVersion := ""
|
||||
if state.InitializeParams != nil {
|
||||
protocolVersion = state.InitializeParams.ProtocolVersion
|
||||
}
|
||||
if protocolVersion == "" {
|
||||
protocolVersion = protocolVersion20250326
|
||||
}
|
||||
c.protocolVersion = negotiatedVersion(protocolVersion)
|
||||
}
|
||||
|
||||
// addBatch records a msgBatch for an incoming batch payload.
|
||||
// It returns an error if batch is malformed, containing previously seen IDs.
|
||||
//
|
||||
// See [msgBatch] for more.
|
||||
func (t *ioConn) addBatch(batch *msgBatch) error {
|
||||
t.batchMu.Lock()
|
||||
defer t.batchMu.Unlock()
|
||||
for id := range batch.unresolved {
|
||||
if _, ok := t.batches[id]; ok {
|
||||
return fmt.Errorf("%w: batch contains previously seen request %v", jsonrpc2.ErrInvalidRequest, id.Raw())
|
||||
}
|
||||
}
|
||||
for id := range batch.unresolved {
|
||||
if t.batches == nil {
|
||||
t.batches = make(map[jsonrpc2.ID]*msgBatch)
|
||||
}
|
||||
t.batches[id] = batch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateBatch records a response in the message batch tracking the
|
||||
// corresponding incoming call, if any.
|
||||
//
|
||||
// The second result reports whether resp was part of a batch. If this is true,
|
||||
// the first result is nil if the batch is still incomplete, or the full set of
|
||||
// batch responses if resp completed the batch.
|
||||
func (t *ioConn) updateBatch(resp *jsonrpc.Response) ([]*jsonrpc.Response, bool) {
|
||||
t.batchMu.Lock()
|
||||
defer t.batchMu.Unlock()
|
||||
|
||||
if batch, ok := t.batches[resp.ID]; ok {
|
||||
idx, ok := batch.unresolved[resp.ID]
|
||||
if !ok {
|
||||
panic("internal error: inconsistent batches")
|
||||
}
|
||||
batch.responses[idx] = resp
|
||||
delete(batch.unresolved, resp.ID)
|
||||
delete(t.batches, resp.ID)
|
||||
if len(batch.unresolved) == 0 {
|
||||
return batch.responses, true
|
||||
}
|
||||
return nil, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// A msgBatch records information about an incoming batch of jsonrpc.2 calls.
|
||||
//
|
||||
// The jsonrpc.2 spec (https://www.jsonrpc.org/specification#batch) says:
|
||||
//
|
||||
// "The Server should respond with an Array containing the corresponding
|
||||
// Response objects, after all of the batch Request objects have been
|
||||
// processed. A Response object SHOULD exist for each Request object, except
|
||||
// that there SHOULD NOT be any Response objects for notifications. The Server
|
||||
// MAY process a batch rpc call as a set of concurrent tasks, processing them
|
||||
// in any order and with any width of parallelism."
|
||||
//
|
||||
// Therefore, a msgBatch keeps track of outstanding calls and their responses.
|
||||
// When there are no unresolved calls, the response payload is sent.
|
||||
type msgBatch struct {
|
||||
unresolved map[jsonrpc2.ID]int
|
||||
responses []*jsonrpc.Response
|
||||
}
|
||||
|
||||
func (t *ioConn) Read(ctx context.Context) (jsonrpc.Message, error) {
|
||||
// As a matter of principle, enforce that reads on a closed context return an
|
||||
// error.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
if len(t.queue) > 0 {
|
||||
next := t.queue[0]
|
||||
t.queue = t.queue[1:]
|
||||
return next, nil
|
||||
}
|
||||
|
||||
var raw json.RawMessage
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
|
||||
case v := <-t.incoming:
|
||||
if v.err != nil {
|
||||
return nil, v.err
|
||||
}
|
||||
raw = v.msg
|
||||
|
||||
case <-t.closed:
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
msgs, batch, err := readBatch(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if batch && t.protocolVersion >= protocolVersion20250618 {
|
||||
return nil, fmt.Errorf("JSON-RPC batching is not supported in %s and later (request version: %s)", protocolVersion20250618, t.protocolVersion)
|
||||
}
|
||||
|
||||
t.queue = msgs[1:]
|
||||
|
||||
if batch {
|
||||
var respBatch *msgBatch // track incoming requests in the batch
|
||||
for _, msg := range msgs {
|
||||
if req, ok := msg.(*jsonrpc.Request); ok {
|
||||
if respBatch == nil {
|
||||
respBatch = &msgBatch{
|
||||
unresolved: make(map[jsonrpc2.ID]int),
|
||||
}
|
||||
}
|
||||
if _, ok := respBatch.unresolved[req.ID]; ok {
|
||||
return nil, fmt.Errorf("duplicate message ID %q", req.ID)
|
||||
}
|
||||
respBatch.unresolved[req.ID] = len(respBatch.responses)
|
||||
respBatch.responses = append(respBatch.responses, nil)
|
||||
}
|
||||
}
|
||||
if respBatch != nil {
|
||||
// The batch contains one or more incoming requests to track.
|
||||
if err := t.addBatch(respBatch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return msgs[0], err
|
||||
}
|
||||
|
||||
// readBatch reads batch data, which may be either a single JSON-RPC message,
|
||||
// or an array of JSON-RPC messages.
|
||||
func readBatch(data []byte) (msgs []jsonrpc.Message, isBatch bool, _ error) {
|
||||
// Try to read an array of messages first.
|
||||
var rawBatch []json.RawMessage
|
||||
if err := internaljson.Unmarshal(data, &rawBatch); err == nil {
|
||||
if len(rawBatch) == 0 {
|
||||
return nil, true, fmt.Errorf("empty batch")
|
||||
}
|
||||
for _, raw := range rawBatch {
|
||||
msg, err := jsonrpc2.DecodeMessage(raw)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
return msgs, true, nil
|
||||
}
|
||||
// Try again with a single message.
|
||||
msg, err := jsonrpc2.DecodeMessage(data)
|
||||
return []jsonrpc.Message{msg}, false, err
|
||||
}
|
||||
|
||||
func (t *ioConn) Write(ctx context.Context, msg jsonrpc.Message) error {
|
||||
// As in [ioConn.Read], enforce that Writes on a closed context are an error.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
t.writeMu.Lock()
|
||||
defer t.writeMu.Unlock()
|
||||
|
||||
// Batching support: if msg is a Response, it may have completed a batch, so
|
||||
// check that first. Otherwise, it is a request or notification, and we may
|
||||
// want to collect it into a batch before sending, if we're configured to use
|
||||
// outgoing batches.
|
||||
if resp, ok := msg.(*jsonrpc.Response); ok {
|
||||
if batch, ok := t.updateBatch(resp); ok {
|
||||
if len(batch) > 0 {
|
||||
data, err := marshalMessages(batch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
_, err = t.rwc.Write(data)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
} else if len(t.outgoingBatch) < cap(t.outgoingBatch) {
|
||||
t.outgoingBatch = append(t.outgoingBatch, msg)
|
||||
if len(t.outgoingBatch) == cap(t.outgoingBatch) {
|
||||
data, err := marshalMessages(t.outgoingBatch)
|
||||
t.outgoingBatch = t.outgoingBatch[:0]
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
_, err = t.rwc.Write(data)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
data, err := jsonrpc2.EncodeMessage(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling message: %v", err)
|
||||
}
|
||||
data = append(data, '\n') // newline delimited
|
||||
_, err = t.rwc.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *ioConn) Close() error {
|
||||
t.closeOnce.Do(func() {
|
||||
t.closeErr = t.rwc.Close()
|
||||
close(t.closed)
|
||||
})
|
||||
return t.closeErr
|
||||
}
|
||||
|
||||
func marshalMessages[T jsonrpc.Message](msgs []T) ([]byte, error) {
|
||||
var rawMsgs []json.RawMessage
|
||||
for _, msg := range msgs {
|
||||
raw, err := jsonrpc2.EncodeMessage(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encoding batch message: %w", err)
|
||||
}
|
||||
rawMsgs = append(rawMsgs, raw)
|
||||
}
|
||||
return json.Marshal(rawMsgs)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// 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"
|
||||
|
||||
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
)
|
||||
|
||||
func assert(cond bool, msg string) {
|
||||
if !cond {
|
||||
panic(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// remarshal marshals from to JSON, and then unmarshals into to, which must be
|
||||
// a pointer type.
|
||||
func remarshal(from, to any) error {
|
||||
data, err := json.Marshal(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := internaljson.Unmarshal(data, to); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user