mcp: export Event (#145)

Export the Event type in preparation for providing user-definable
storage for resumable streams.

Also, move event code to a separate file.

This PR is just renaming and code motion.
This commit is contained in:
Jonathan Amsterdam 2025-07-21 10:54:59 -04:00 • committed by GitHub
commit a519c182e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 256 additions and 227 deletions

138
mcp/event.go Normal file
View file

@ -0,0 +1,138 @@
// 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"
"errors"
"fmt"
"io"
"iter"
"net/http"
"strings"
)
// 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
}
// Empty reports whether the Event is empty.
func (e Event) Empty() bool {
return e.Name == "" && e.ID == "" && len(e.Data) == 0
}
// 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)
}
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] {
scanner := bufio.NewScanner(r)
const maxTokenSize = 1 * 1024 * 1024 // 1 MiB max line size
scanner.Buffer(nil, maxTokenSize)
// 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")
)
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
)
flushData := func() {
if dataBuf != nil {
evt.Data = dataBuf.Bytes()
dataBuf = nil
}
}
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
flushData()
// \n\n is the record delimiter
if !evt.Empty() && !yield(evt, nil) {
return
}
evt = Event{}
continue
}
before, after, found := bytes.Cut(line, []byte{':'})
if !found {
yield(Event{}, fmt.Errorf("malformed line in SSE stream: %q", string(line)))
return
}
if !bytes.Equal(before, dataKey) {
flushData()
}
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, dataKey):
data := bytes.TrimSpace(after)
if dataBuf != nil {
dataBuf.WriteByte('\n')
dataBuf.Write(data)
} else {
dataBuf = new(bytes.Buffer)
dataBuf.Write(data)
}
}
}
if err := scanner.Err(); err != nil {
if errors.Is(err, bufio.ErrTooLong) {
err = fmt.Errorf("event exceeded max line length of %d", maxTokenSize)
}
if !yield(Event{}, err) {
return
}
}
flushData()
if !evt.Empty() {
yield(evt, nil)
}
}
}

99
mcp/event_test.go Normal file
View file

@ -0,0 +1,99 @@
// 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 (
"strings"
"testing"
)
func TestScanEvents(t *testing.T) {
tests := []struct {
name string
input string
want []Event
wantErr string
}{
{
name: "simple event",
input: "event: message\nid: 1\ndata: hello\n\n",
want: []Event{
{Name: "message", ID: "1", Data: []byte("hello")},
},
},
{
name: "multiple data lines",
input: "data: line 1\ndata: line 2\n\n",
want: []Event{
{Data: []byte("line 1\nline 2")},
},
},
{
name: "multiple events",
input: "data: first\n\nevent: second\ndata: second\n\n",
want: []Event{
{Data: []byte("first")},
{Name: "second", Data: []byte("second")},
},
},
{
name: "no trailing newline",
input: "data: hello",
want: []Event{
{Data: []byte("hello")},
},
},
{
name: "malformed line",
input: "invalid line\n\n",
wantErr: "malformed line",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := strings.NewReader(tt.input)
var got []Event
var err error
for e, err2 := range scanEvents(r) {
if err2 != nil {
err = err2
break
}
got = append(got, e)
}
if tt.wantErr != "" {
if err == nil {
t.Fatalf("scanEvents() got nil error, want error containing %q", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("scanEvents() error = %q, want containing %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("scanEvents() returned unexpected error: %v", err)
}
if len(got) != len(tt.want) {
t.Fatalf("scanEvents() got %d events, want %d", len(got), len(tt.want))
}
for i := range got {
if g, w := got[i].Name, tt.want[i].Name; g != w {
t.Errorf("event %d: name = %q, want %q", i, g, w)
}
if g, w := got[i].ID, tt.want[i].ID; g != w {
t.Errorf("event %d: id = %q, want %q", i, g, w)
}
if g, w := string(got[i].Data), string(tt.want[i].Data); g != w {
t.Errorf("event %d: data = %q, want %q", i, g, w)
}
}
})
}
}

View file

@ -5,16 +5,12 @@
package mcp
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"iter"
"net/http"
"net/url"
"strings"
"sync"
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
@ -41,34 +37,6 @@ import (
// - Read reads off a message queue that is pushed to via POST requests.
// - Close causes the hanging GET to exit.
// An event is a server-sent event.
type event struct {
name string
id string
data []byte
}
func (e event) empty() bool {
return e.name == "" && e.id == "" && len(e.data) == 0
}
// 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)
}
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
}
// SSEHandler is an http.Handler that serves SSE-based MCP sessions as defined by
// the [2024-11-05 version] of the MCP spec.
//
@ -172,9 +140,9 @@ func (t *SSEServerTransport) ServeHTTP(w http.ResponseWriter, req *http.Request)
// See [SSEServerTransport] for more details on the [Connection] implementation.
func (t *SSEServerTransport) Connect(context.Context) (Connection, error) {
t.mu.Lock()
_, err := writeEvent(t.w, event{
name: "endpoint",
data: []byte(t.endpoint),
_, err := writeEvent(t.w, Event{
Name: "endpoint",
Data: []byte(t.endpoint),
})
t.mu.Unlock()
if err != nil {
@ -300,7 +268,7 @@ func (s sseServerConn) Write(ctx context.Context, msg jsonrpc.Message) error {
return io.EOF
}
_, err = writeEvent(s.t.w, event{name: "message", data: data})
_, err = writeEvent(s.t.w, Event{Name: "message", Data: data})
return err
}
@ -372,17 +340,17 @@ func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) {
}
msgEndpoint, err := func() (*url.URL, error) {
var evt event
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")
if evt.Name != "endpoint" {
return nil, fmt.Errorf("first event is %q, want %q", evt.Name, "endpoint")
}
raw := string(evt.data)
raw := string(evt.Data)
return c.sseEndpoint.Parse(raw)
}()
if err != nil {
@ -408,7 +376,7 @@ func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) {
return
}
select {
case s.incoming <- evt.data:
case s.incoming <- evt.Data:
case <-s.done:
return
}
@ -418,95 +386,6 @@ func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) {
return s, nil
}
// 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] {
scanner := bufio.NewScanner(r)
const maxTokenSize = 1 * 1024 * 1024 // 1 MiB max line size
scanner.Buffer(nil, maxTokenSize)
// 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")
)
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
)
flushData := func() {
if dataBuf != nil {
evt.data = dataBuf.Bytes()
dataBuf = nil
}
}
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
flushData()
// \n\n is the record delimiter
if !evt.empty() && !yield(evt, nil) {
return
}
evt = event{}
continue
}
before, after, found := bytes.Cut(line, []byte{':'})
if !found {
yield(event{}, fmt.Errorf("malformed line in SSE stream: %q", string(line)))
return
}
if !bytes.Equal(before, dataKey) {
flushData()
}
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, dataKey):
data := bytes.TrimSpace(after)
if dataBuf != nil {
dataBuf.WriteByte('\n')
dataBuf.Write(data)
} else {
dataBuf = new(bytes.Buffer)
dataBuf.Write(data)
}
}
}
if err := scanner.Err(); err != nil {
if errors.Is(err, bufio.ErrTooLong) {
err = fmt.Errorf("event exceeded max line length of %d", maxTokenSize)
}
if !yield(event{}, err) {
return
}
}
flushData()
if !evt.empty() {
yield(evt, nil)
}
}
}
// An sseClientConn is a logical jsonrpc2 connection that implements the client
// half of the SSE protocol:
// - Writes are POSTS to the session endpoint.

View file

@ -9,7 +9,6 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
@ -90,95 +89,6 @@ func TestSSEServer(t *testing.T) {
}
}
func TestScanEvents(t *testing.T) {
tests := []struct {
name string
input string
want []event
wantErr string
}{
{
name: "simple event",
input: "event: message\nid: 1\ndata: hello\n\n",
want: []event{
{name: "message", id: "1", data: []byte("hello")},
},
},
{
name: "multiple data lines",
input: "data: line 1\ndata: line 2\n\n",
want: []event{
{data: []byte("line 1\nline 2")},
},
},
{
name: "multiple events",
input: "data: first\n\nevent: second\ndata: second\n\n",
want: []event{
{data: []byte("first")},
{name: "second", data: []byte("second")},
},
},
{
name: "no trailing newline",
input: "data: hello",
want: []event{
{data: []byte("hello")},
},
},
{
name: "malformed line",
input: "invalid line\n\n",
wantErr: "malformed line",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := strings.NewReader(tt.input)
var got []event
var err error
for e, err2 := range scanEvents(r) {
if err2 != nil {
err = err2
break
}
got = append(got, e)
}
if tt.wantErr != "" {
if err == nil {
t.Fatalf("scanEvents() got nil error, want error containing %q", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("scanEvents() error = %q, want containing %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("scanEvents() returned unexpected error: %v", err)
}
if len(got) != len(tt.want) {
t.Fatalf("scanEvents() got %d events, want %d", len(got), len(tt.want))
}
for i := range got {
if g, w := got[i].name, tt.want[i].name; g != w {
t.Errorf("event %d: name = %q, want %q", i, g, w)
}
if g, w := got[i].id, tt.want[i].id; g != w {
t.Errorf("event %d: id = %q, want %q", i, g, w)
}
if g, w := string(got[i].data), string(tt.want[i].data); g != w {
t.Errorf("event %d: data = %q, want %q", i, g, w)
}
}
})
}
}
// roundTripperFunc is a helper to create a custom RoundTripper
type roundTripperFunc func(*http.Request) (*http.Response, error)

View file

@ -149,6 +149,9 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
// NewStreamableServerTransport returns a new [StreamableServerTransport] with
// the given session ID.
// The session ID must be globally unique, that is, different from any other
// session ID anywhere, past and future. (We recommend using a crypto random number
// generator to produce one, as in [crypto/rand.Text].)
//
// A StreamableServerTransport implements the server-side of the streamable
// transport.
@ -246,7 +249,7 @@ type streamID int64
// a streamableMsg is an SSE event with an index into its logical stream.
type streamableMsg struct {
idx int
event event
event Event
}
// Connect implements the [Transport] interface.
@ -549,10 +552,10 @@ func (t *StreamableServerTransport) Write(ctx context.Context, msg jsonrpc.Messa
idx := len(t.outgoingMessages[forConn])
t.outgoingMessages[forConn] = append(t.outgoingMessages[forConn], &streamableMsg{
idx: idx,
event: event{
name: "message",
id: formatEventID(forConn, idx),
data: data,
event: Event{
Name: "message",
ID: formatEventID(forConn, idx),
Data: data,
},
})
if replyTo.IsValid() {
@ -768,7 +771,7 @@ func (s *streamableClientConn) handleSSE(resp *http.Response) {
select {
case <-s.done:
return
case s.incoming <- evt.data:
case s.incoming <- evt.Data:
}
}
}()

View file

@ -520,7 +520,7 @@ func streamingRequest(ctx context.Context, serverURL, sessionID, method string,
}
// TODO(rfindley): do we need to check evt.name?
// Does the MCP spec say anything about this?
msg, err := jsonrpc2.DecodeMessage(evt.data)
msg, err := jsonrpc2.DecodeMessage(evt.Data)
if err != nil {
return newSessionID, resp.StatusCode, fmt.Errorf("decoding message: %w", err)
}