Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
90a0b1f
mcp: fix comments (#241)
cryo-zd Aug 5, 2025
31c18fb
docs: fix middleware example function signature (#236)
winterfx Aug 5, 2025
272a5ac
.github: add race and 1.25 tests
findleyr Aug 5, 2025
f4a9396
mcp: fix a bug causing premature termination of streams
findleyr Aug 5, 2025
be0bd77
mcp: test nil params crash vulnerability
davidleitw Aug 5, 2025
032f03b
jsonschema: add ForType function (#242)
jba Aug 5, 2025
a02c2ff
mcp: unblock reads of ioConn as soon as Close is called
findleyr Aug 4, 2025
bb9087d
mcp: handle bad trailing stdio input graciously (#179) (#192)
aryannr97 Aug 6, 2025
c132621
jsonschema: marshal to bools (#247)
jba Aug 6, 2025
e00c485
examples: add a 'listeverything' client example, and reorganize
findleyr Jul 30, 2025
b392875
internal/util: move FieldJSONInfo into jsonschema/util (#251)
samthanawalla Aug 6, 2025
5bd02a3
jsonschema: copy wrapf into jsonschema/util.go (#254)
samthanawalla Aug 6, 2025
4608401
mcp: simplify ServerSession.initialize
findleyr Aug 7, 2025
5ab63fe
mcp: don't mark the server sesion as initialized prematurely
findleyr Aug 7, 2025
e9e0da8
jsonschema: remove jsonschema code and depend on google/jsonschema-go…
samthanawalla Aug 7, 2025
8186bf3
mcp: lock down Params and Result
findleyr Aug 7, 2025
e2cf95c
auth: add OAuth authenticating middleware for server (#261)
jba Aug 8, 2025
c03cd68
internal/jsonrpc2: allow asynchronous writes
findleyr Aug 8, 2025
6ea7a6c
mcp: simplify and fix the streamable client transport
findleyr Aug 8, 2025
0375535
mcp: implement support for JSON responses in the MCP streamable server
findleyr Aug 8, 2025
388e000
.github: update go 1.25 builds to rc3 (#271)
findleyr Aug 9, 2025
4e413da
mcp: rename mcp{Params,Result} to is{Params,Result} (#270)
findleyr Aug 9, 2025
52f6ff4
server: support distributed sessions
jba Aug 4, 2025
d9bf37e
add Connect arg (#232)
jba Aug 4, 2025
5b44363
fix race (#232)
jba Aug 9, 2025
5ebbb94
load/save state for each method; add test
jba Aug 11, 2025
748de2b
fix data race
jba Aug 11, 2025
0b72210
fix deadlock
jba Aug 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
name: Test
on:
# Manual trigger
workflow_dispatch:
workflow_dispatch:
push:
branches: main
pull_request:

permissions:
contents: read

jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Set up Go
uses: actions/setup-go@v5
- name: Check out code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
Expand All @@ -26,18 +26,30 @@ jobs:
exit 1
fi
echo "All Go files are properly formatted"

test:
runs-on: ubuntu-latest
strategy:
matrix:
go: [ '1.23', '1.24' ]
go: [ '1.23', '1.24', '1.25.0-rc.3' ]
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Check out code
uses: actions/checkout@v4
- name: Test
run: go test -v ./...

race-test:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Test with -race
run: go test -v -race ./...
96 changes: 96 additions & 0 deletions auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// 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 auth

import (
"context"
"errors"
"net/http"
"slices"
"strings"
"time"
)

type TokenInfo struct {
Scopes []string
Expiration time.Time
}

type TokenVerifier func(ctx context.Context, token string) (*TokenInfo, error)

type RequireBearerTokenOptions struct {
Scopes []string
ResourceMetadataURL string
}

var ErrInvalidToken = errors.New("invalid token")

type tokenInfoKey struct{}

// RequireBearerToken returns a piece of middleware that verifies a bearer token using the verifier.
// If verification succeeds, the [TokenInfo] is added to the request's context and the request proceeds.
// If verification fails, the request fails with a 401 Unauthenticated, and the WWW-Authenticate header
// is populated to enable [protected resource metadata].
//
// [protected resource metadata]: https://datatracker.ietf.org/doc/rfc9728
func RequireBearerToken(verifier TokenVerifier, opts *RequireBearerTokenOptions) func(http.Handler) http.Handler {
// Based on typescript-sdk/src/server/auth/middleware/bearerAuth.ts.

return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenInfo, errmsg, code := verify(r.Context(), verifier, opts, r.Header.Get("Authorization"))
if code != 0 {
if code == http.StatusUnauthorized || code == http.StatusForbidden {
if opts != nil && opts.ResourceMetadataURL != "" {
w.Header().Add("WWW-Authenticate", "Bearer resource_metadata="+opts.ResourceMetadataURL)
}
}
http.Error(w, errmsg, code)
return
}
r = r.WithContext(context.WithValue(r.Context(), tokenInfoKey{}, tokenInfo))
handler.ServeHTTP(w, r)
})
}
}

func verify(ctx context.Context, verifier TokenVerifier, opts *RequireBearerTokenOptions, authHeader string) (_ *TokenInfo, errmsg string, code int) {
// Extract bearer token.
fields := strings.Fields(authHeader)
if len(fields) != 2 || strings.ToLower(fields[0]) != "bearer" {
return nil, "no bearer token", http.StatusUnauthorized
}

// Verify the token and get information from it.
tokenInfo, err := verifier(ctx, fields[1])
if err != nil {
if errors.Is(err, ErrInvalidToken) {
return nil, err.Error(), http.StatusUnauthorized
}
// TODO: the TS SDK distinguishes another error, OAuthError, and returns a 400.
// Investigate how that works.
// See typescript-sdk/src/server/auth/middleware/bearerAuth.ts.
return nil, err.Error(), http.StatusInternalServerError
}

// Check scopes.
if opts != nil {
// Note: quadratic, but N is small.
for _, s := range opts.Scopes {
if !slices.Contains(tokenInfo.Scopes, s) {
return nil, "insufficient scope", http.StatusForbidden
}
}
}

// Check expiration.
if tokenInfo.Expiration.IsZero() {
return nil, "token missing expiration", http.StatusUnauthorized
}
if tokenInfo.Expiration.Before(time.Now()) {
return nil, "token expired", http.StatusUnauthorized
}
return tokenInfo, "", 0
}
70 changes: 70 additions & 0 deletions auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.

package auth

import (
"context"
"errors"
"testing"
"time"
)

func TestVerify(t *testing.T) {
ctx := context.Background()
verifier := func(_ context.Context, token string) (*TokenInfo, error) {
switch token {
case "valid":
return &TokenInfo{Expiration: time.Now().Add(time.Hour)}, nil
case "invalid":
return nil, ErrInvalidToken
case "noexp":
return &TokenInfo{}, nil
case "expired":
return &TokenInfo{Expiration: time.Now().Add(-time.Hour)}, nil
default:
return nil, errors.New("unknown")
}
}

for _, tt := range []struct {
name string
opts *RequireBearerTokenOptions
header string
wantMsg string
wantCode int
}{
{
"valid", nil, "Bearer valid",
"", 0,
},
{
"bad header", nil, "Barer valid",
"no bearer token", 401,
},
{
"invalid", nil, "bearer invalid",
"invalid token", 401,
},
{
"no expiration", nil, "Bearer noexp",
"token missing expiration", 401,
},
{
"expired", nil, "Bearer expired",
"token expired", 401,
},
{
"missing scope", &RequireBearerTokenOptions{Scopes: []string{"s1"}}, "Bearer valid",
"insufficient scope", 403,
},
} {
t.Run(tt.name, func(t *testing.T) {
_, gotMsg, gotCode := verify(ctx, verifier, tt.opts, tt.header)
if gotMsg != tt.wantMsg || gotCode != tt.wantCode {
t.Errorf("got (%q, %d), want (%q, %d)", gotMsg, gotCode, tt.wantMsg, tt.wantCode)
}
})
}
}
4 changes: 2 additions & 2 deletions design/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,8 +456,8 @@ func (s *Server) AddReceivingMiddleware(middleware ...Middleware[*ServerSession]
As an example, this code adds server-side logging:

```go
func withLogging(h mcp.MethodHandler[*ServerSession]) mcp.MethodHandler[*ServerSession]{
return func(ctx context.Context, s *mcp.ServerSession, method string, params any) (res any, err error) {
func withLogging(h mcp.MethodHandler[*mcp.ServerSession]) mcp.MethodHandler[*mcp.ServerSession]{
return func(ctx context.Context, s *mcp.ServerSession, method string, params mcp.Params) (res mcp.Result, err error) {
log.Printf("request: %s %v", method, params)
defer func() { log.Printf("response: %v, %v", res, err) }()
return h(ctx, s , method, params)
Expand Down
65 changes: 65 additions & 0 deletions examples/client/listfeatures/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.

// The listfeatures command lists all features of a stdio MCP server.
//
// Usage: listfeatures <command> [<args>]
//
// For example:
//
// listfeatures go run github.com/modelcontextprotocol/go-sdk/examples/server/hello
//
// or
//
// listfeatures npx @modelcontextprotocol/server-everything
package main

import (
"context"
"flag"
"fmt"
"iter"
"log"
"os"
"os/exec"

"github.com/modelcontextprotocol/go-sdk/mcp"
)

func main() {
flag.Parse()
args := flag.Args()
if len(args) == 0 {
fmt.Fprintf(os.Stderr, "Usage: listfeatures <command> [<args>]")
fmt.Fprintf(os.Stderr, "List all features for a stdio MCP server")
fmt.Fprintln(os.Stderr)
fmt.Fprintf(os.Stderr, "Example: listfeatures npx @modelcontextprotocol/server-everything")
os.Exit(2)
}

ctx := context.Background()
cmd := exec.Command(args[0], args[1:]...)
client := mcp.NewClient(&mcp.Implementation{Name: "mcp-client", Version: "v1.0.0"}, nil)
cs, err := client.Connect(ctx, mcp.NewCommandTransport(cmd))
if err != nil {
log.Fatal(err)
}
defer cs.Close()

printSection("tools", cs.Tools(ctx, nil), func(t *mcp.Tool) string { return t.Name })
printSection("resources", cs.Resources(ctx, nil), func(r *mcp.Resource) string { return r.Name })
printSection("resource templates", cs.ResourceTemplates(ctx, nil), func(r *mcp.ResourceTemplate) string { return r.Name })
printSection("prompts", cs.Prompts(ctx, nil), func(p *mcp.Prompt) string { return p.Name })
}

func printSection[T any](name string, features iter.Seq2[T, error], featName func(T) string) {
fmt.Printf("%s:\n", name)
for feat, err := range features {
if err != nil {
log.Fatal(err)
}
fmt.Printf("\t%s\n", featName(feat))
}
fmt.Println()
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.23.0

require (
github.com/google/go-cmp v0.7.0
github.com/google/jsonschema-go v0.2.0
github.com/yosida95/uritemplate/v3 v3.0.2
golang.org/x/tools v0.34.0
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.2.0 h1:Uh19091iHC56//WOsAd1oRg6yy1P9BpSvpjOL6RcjLQ=
github.com/google/jsonschema-go v0.2.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
Expand Down
13 changes: 4 additions & 9 deletions internal/jsonrpc2/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,7 @@ type Connection struct {
state inFlightState // accessed only in updateInFlight
done chan struct{} // closed (under stateMu) when state.closed is true and all goroutines have completed

writer chan Writer // 1-buffered; stores the writer when not in use

writer Writer
handler Handler

onInternalError func(error)
Expand Down Expand Up @@ -214,12 +213,11 @@ func NewConnection(ctx context.Context, cfg ConnectionConfig) *Connection {
c := &Connection{
state: inFlightState{closer: cfg.Closer},
done: make(chan struct{}),
writer: make(chan Writer, 1),
writer: cfg.Writer,
onDone: cfg.OnDone,
onInternalError: cfg.OnInternalError,
}
c.handler = cfg.Bind(c)
c.writer <- cfg.Writer
c.start(ctx, cfg.Reader, cfg.Preempter)
return c
}
Expand All @@ -239,7 +237,6 @@ func bindConnection(bindCtx context.Context, rwc io.ReadWriteCloser, binder Bind
c := &Connection{
state: inFlightState{closer: rwc},
done: make(chan struct{}),
writer: make(chan Writer, 1),
onDone: onDone,
}
// It's tempting to set a finalizer on c to verify that the state has gone
Expand All @@ -259,7 +256,7 @@ func bindConnection(bindCtx context.Context, rwc io.ReadWriteCloser, binder Bind
}
c.onInternalError = options.OnInternalError

c.writer <- framer.Writer(rwc)
c.writer = framer.Writer(rwc)
reader := framer.Reader(rwc)
c.start(ctx, reader, options.Preempter)
return c
Expand Down Expand Up @@ -728,9 +725,7 @@ func (c *Connection) processResult(from any, req *incomingRequest, result any, e
// write is used by all things that write outgoing messages, including replies.
// it makes sure that writes are atomic
func (c *Connection) write(ctx context.Context, msg Message) error {
writer := <-c.writer
defer func() { c.writer <- writer }()
err := writer.Write(ctx, msg)
err := c.writer.Write(ctx, msg)

if err != nil && ctx.Err() == nil {
// The call to Write failed, and since ctx.Err() is nil we can't attribute
Expand Down
Loading