Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
2fca1b4
feat: implement sessionless protocol support via per-request _meta va…
guglielmo-san May 19, 2026
98b2a44
fix: correctly report MethodNotFound error codes in new-protocol requ…
guglielmo-san May 19, 2026
222d145
refactor: remove legacy stateless session handling logic and associat…
guglielmo-san May 19, 2026
4beb079
feat: enforce SEP-2575 protocol version header validation and restric…
guglielmo-san May 20, 2026
23b804b
test: add required Mcp-Method and Mcp-Name headers to streamable inte…
guglielmo-san May 20, 2026
113cc9f
fix: reject initialize, ping, and notifications/initialized methods i…
guglielmo-san May 20, 2026
85a36ed
docs: simplify protocol version requirement comment in streamable.go
guglielmo-san May 20, 2026
aecfae4
docs: update validateRequestMeta comment grammar to present tense
guglielmo-san May 20, 2026
69a549f
refactor: update protocol version retrieval to use context instead of…
guglielmo-san May 20, 2026
584f8bf
feat: implement SEP-2575 server/discover protocol for stateless clien…
guglielmo-san May 21, 2026
67233a6
refactor: add isNil interface method to all param structs and update …
guglielmo-san May 21, 2026
52828cd
refactor: remove redundant meta field from validatedMeta and simplify…
guglielmo-san May 21, 2026
ded4e45
test: update streamable handler tests to inject required client metad…
guglielmo-san May 21, 2026
aba9529
style: align whitespace in isParams method declarations for consisten…
guglielmo-san May 21, 2026
e931735
test: update MCP tests to handle server/discover fallback to legacy i…
guglielmo-san May 21, 2026
f809bb2
Merge branch 'guglielmoc/SEP-2567_2575_Stateless_MCP' into guglielmoc…
guglielmo-san May 21, 2026
6cc84d2
feat: add isNil helper method to DiscoverParams struct
guglielmo-san May 21, 2026
fc5865b
refactor: simplify orZero helper implementation and remove deprecated…
guglielmo-san May 21, 2026
b1a06ba
feat: extract and persist initialize params from new protocol request…
guglielmo-san May 21, 2026
2e2a116
refactor: rename usesNewProtocol variable to validatedMeta for clarit…
guglielmo-san May 21, 2026
8b572a6
refactor: update ServerSessionState using thread-safe updateState hel…
guglielmo-san May 21, 2026
6f1eba0
fix: prevent redundant initialization of server session state when al…
guglielmo-san May 22, 2026
ddec24b
feat: implement SEP-2575 handshake support with version-aware transpo…
guglielmo-san May 25, 2026
22c0c7d
refactor: update protocol versioning, remove client keepalive initial…
guglielmo-san May 25, 2026
31b343c
fix: propagate discovery errors and add Bad Request to legacy fallbac…
guglielmo-san May 25, 2026
38d8b59
refactor: update MCP discovery logic to handle protocol version fallb…
guglielmo-san May 25, 2026
9e3da0c
refactor: replace protocolVersionSetter interface with context-based …
guglielmo-san May 25, 2026
372c6ee
test: add discoverInterceptor to test suite and clean up server-side …
guglielmo-san May 25, 2026
b145443
Merge branch 'guglielmoc/SEP-2567_2575_Stateless_MCP' into guglielmoc…
guglielmo-san May 25, 2026
a0e6114
feat: add discover method stub to server and register in methodInfos
guglielmo-san May 25, 2026
6724ae9
Merge remote-tracking branch 'origin/main' into guglielmoc/SEP-2575_S…
guglielmo-san May 26, 2026
54dedd1
fix: update fallback logic to include Bad Request errors and rename i…
guglielmo-san May 26, 2026
2618874
test: remove server/discover calls and update session configuration i…
guglielmo-san May 26, 2026
3747e9f
refactor: delay initialization state updates and enforce protocol ver…
guglielmo-san May 26, 2026
4b985de
refactor: update protocol version negotiation to select the highest m…
guglielmo-san May 26, 2026
5292ccb
refactor: simplify SSE stream logic for SEP-2575 and update discover …
guglielmo-san May 26, 2026
15e4bc3
refactor: simplify error wrapping by removing redundant errors.Join i…
guglielmo-san May 26, 2026
1b6fe5c
fix: handle nil params and remove manual metadata injection in client…
guglielmo-san May 26, 2026
96cef62
refactor: improve parameter handling with generic parameter types and…
guglielmo-san May 27, 2026
ad296c3
refactor: align request parameter handling to support SEP-2575 _meta …
guglielmo-san May 27, 2026
2fb3775
feat: consolidate SEP-2575 metadata injection into ClientSession methods
guglielmo-san May 27, 2026
d17b423
refactor: consolidate protocol version extraction into a helper function
guglielmo-san May 28, 2026
854bb42
fix: update client to trigger fallback when vPre servers return HTTP …
guglielmo-san May 28, 2026
4cc3aa7
feat: implement protocol version fallback for MCP server connections …
guglielmo-san May 28, 2026
02ce00b
run formatter
guglielmo-san May 28, 2026
6f1f416
refactor: convert injectRequestMeta to a generic function that handle…
guglielmo-san May 29, 2026
8a38b09
Merge branch 'main' into guglielmoc/SEP-2575_Stateless_MCP_part2
guglielmo-san May 29, 2026
ae219d9
feat: prioritize current protocol version and add test middleware for…
guglielmo-san May 29, 2026
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
2 changes: 1 addition & 1 deletion internal/jsonrpc2/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,7 @@ func (c *Connection) write(ctx context.Context, msg Message) error {

// For cancelled or rejected requests, we don't set the writeErr (which would
// break the connection). They can just be returned to the caller.
if err != nil && ctx.Err() == nil && !errors.Is(err, ErrRejected) {
if err != nil && ctx.Err() == nil && !errors.Is(err, ErrRejected) && !errors.Is(err, ErrUnsupportedProtocolVersion) && !errors.Is(err, ErrMethodNotFound) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if any server implementation close a connection on their side in case of an unknown method request

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case it will not pass through this path. The server will send a request from the SSE stream, and the client will respond with a new response that will eventually contain the error. But that will be a new POST Response

// The call to Write failed, and since ctx.Err() is nil we can't attribute
// the failure (even indirectly) to Context cancellation. The writer appears
// to be broken, and future writes are likely to also fail.
Expand Down
4 changes: 3 additions & 1 deletion internal/jsonrpc2/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ var (
// ErrUnknown should be used for all non coded errors.
ErrUnknown = NewError(-32001, "unknown error")
// ErrServerClosing is returned for calls that arrive while the server is closing.
ErrServerClosing = NewError(-32004, "server is closing")
ErrServerClosing = NewError(-32006, "server is closing")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, that's not nice . SDK claimed error codes which are now also used in the spec.

ErrClientClosing = NewError(-32003, "client is closing")

will now mean MISSING_REQUIRED_CLIENT_CAPABILITY.

for the server side we can do mcgdebug for returning -32004 when server is closing for a session with and old protocol version.
I guess we need to handle it in internal/jsonrpc2/wire.go is-check looking not just at the code but at the message as well 😞

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure this is needed. The ErrClientClosing and ErrServerClosing are never sent over the wire and the error code is never returned to the calling function. Are only used internally in the client / server implementation.

// ErrClientClosing is a dummy error returned for calls initiated while the client is closing.
ErrClientClosing = NewError(-32003, "client is closing")

Expand All @@ -45,6 +45,8 @@ var (
// should be returned to the caller to indicate that the specific request is
// invalid in the current context.
ErrRejected = NewError(-32005, "rejected by transport")
// ErrUnsupportedProtocolVersion is returned when a server does not support the protocol version.
ErrUnsupportedProtocolVersion = NewError(-32004, "unsupported protocol version")
)

const wireVersion = "2.0"
Expand Down
142 changes: 142 additions & 0 deletions mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ type ClientOptions struct {
// If non-zero, defines an interval for regular "ping" requests.
// If the peer fails to respond to pings originating from the keepalive check,
// the session is automatically closed.
// NOTE: The keepalive feature is only available for protocol versions < 2026-06-30
KeepAlive time.Duration
}

Expand Down Expand Up @@ -276,6 +277,27 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp
if opts != nil && opts.protocolVersion != "" {
protocolVersion = opts.protocolVersion
}

if protocolVersion >= protocolVersion20260630 {
// Per SEP-2575, try the stateless server/discover RPC first. If the server
// signals it doesn't support it, fall back to the legacy initialize
// handshake.
discoverCtx := context.WithValue(ctx, protocolVersionContextKey{}, protocolVersion)
discRes, fallback, err := c.discover(discoverCtx, cs)
if err != nil {
return nil, err
}
if !fallback {
cs.state.InitializeResult = discRes
if hc, ok := cs.mcpConn.(clientConnection); ok {
hc.sessionUpdated(cs.state)
}
return cs, nil
}
// Fallback to the legacy initialize handshake.
protocolVersion = protocolVersion20251125
}

params := &InitializeParams{
ProtocolVersion: protocolVersion,
ClientInfo: c.impl,
Expand Down Expand Up @@ -307,6 +329,66 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp
return cs, nil
}

// discover sends a SEP-2575 server/discover request to probe the server for
// stateless protocol support.
//
// The return values have three possible combinations:
// - (result, false, nil): discovery succeeded; caller should skip legacy initialization.
// - (nil, true, nil): the server explicitly signaled it doesn't support
// discovery (Method not found, or UnsupportedProtocolVersionError, or version mismatch);
// caller should fall back to the legacy initialize handshake.
// - (nil, false, err): any other failure (transport error, malformed response, etc.);
// caller should propagate the error.
func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeResult, bool, error) {
protocolVersion := protocolVersionFromContext(ctx)
caps := c.capabilities(protocolVersion)
params := &DiscoverParams{
Meta: Meta{
MetaKeyProtocolVersion: protocolVersion,
MetaKeyClientInfo: c.impl,
MetaKeyClientCapabilities: caps,
},
}
req := &DiscoverRequest{Session: cs, Params: params}
res, err := handleSend[*DiscoverResult](ctx, methodDiscover, req)
if err != nil {
// According to SEP-2575, only the two signals below (MethodNotFound
// and UnsupportedProtocolVersionError) should trigger a fallback.
var werr *jsonrpc.Error
if errors.As(err, &werr) && (werr.Code == jsonrpc.CodeMethodNotFound || werr.Code == CodeUnsupportedProtocolVersion) {
return nil, true, nil
}
return nil, false, err
}

// Pick the highest protocol version that both the server and this SDK support.
// Since supportedProtocolVersions is defined in descending order (newest to oldest),
// the first match we find is the highest supported version.
var negotiated string
if slices.Contains(res.SupportedVersions, protocolVersion) {
negotiated = protocolVersion
} else {
for _, v := range supportedProtocolVersions {
if slices.Contains(res.SupportedVersions, v) {
negotiated = v
break
}
}
}
if negotiated == "" || negotiated < protocolVersion20260630 {
// If there is no overlap, fall back to initialize so version
// negotiation can happen via the legacy path.
return nil, true, nil
}

return &InitializeResult{
Capabilities: res.Capabilities,
Instructions: res.Instructions,
ProtocolVersion: negotiated,
ServerInfo: res.ServerInfo,
}, false, nil
}

// A ClientSession is a logical connection with an MCP server. Its
// methods can be used to send requests or notifications to the server. Create
// a session by calling [Client.Connect].
Expand Down Expand Up @@ -347,6 +429,42 @@ type clientSessionState struct {

func (cs *ClientSession) InitializeResult() *InitializeResult { return cs.state.InitializeResult }

// usesNewProtocol reports whether this session has negotiated a protocol
// version >= 2026-06-30, which requires the SEP-2575 per-request `_meta`
// triple on every outgoing request.
func (cs *ClientSession) usesNewProtocol() bool {
res := cs.state.InitializeResult
return res != nil && res.ProtocolVersion >= protocolVersion20260630
}

// injectRequestMeta populates the SEP-2575 per-request `_meta` triple
// (protocolVersion, clientInfo, clientCapabilities) on the given outgoing
// request params. Keys already present in params.Meta are not overwritten.
func injectRequestMeta[T any, P interface {
*T
Params
}](cs *ClientSession, params P) P {
res := cs.state.InitializeResult
if params.isNil() {
params = new(T)
}
m := params.GetMeta()
if m == nil {
m = map[string]any{}
}
if _, ok := m[MetaKeyProtocolVersion]; !ok {
m[MetaKeyProtocolVersion] = res.ProtocolVersion
}
if _, ok := m[MetaKeyClientInfo]; !ok {
m[MetaKeyClientInfo] = cs.client.impl
}
if _, ok := m[MetaKeyClientCapabilities]; !ok {
m[MetaKeyClientCapabilities] = cs.client.capabilities(res.ProtocolVersion)
}
params.SetMeta(m)
return params
}

func (cs *ClientSession) ID() string {
if c, ok := cs.mcpConn.(hasSessionID); ok {
return c.SessionID()
Expand Down Expand Up @@ -1007,16 +1125,25 @@ func (cs *ClientSession) Ping(ctx context.Context, params *PingParams) error {

// ListPrompts lists prompts that are currently available on the server.
func (cs *ClientSession) ListPrompts(ctx context.Context, params *ListPromptsParams) (*ListPromptsResult, error) {
if cs.usesNewProtocol() {
params = injectRequestMeta(cs, params)
}
return handleSend[*ListPromptsResult](ctx, methodListPrompts, newClientRequest(cs, orZero[Params](params)))
}

// GetPrompt gets a prompt from the server.
func (cs *ClientSession) GetPrompt(ctx context.Context, params *GetPromptParams) (*GetPromptResult, error) {
if cs.usesNewProtocol() {
params = injectRequestMeta(cs, params)
}
return handleSend[*GetPromptResult](ctx, methodGetPrompt, newClientRequest(cs, orZero[Params](params)))
}

// ListTools lists tools that are currently available on the server.
func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) (*ListToolsResult, error) {
if cs.usesNewProtocol() {
params = injectRequestMeta(cs, params)
}
result, err := handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, orZero[Params](params)))
if err != nil {
return nil, err
Expand All @@ -1040,6 +1167,9 @@ func (cs *ClientSession) CallTool(ctx context.Context, params *CallToolParams) (
if tool := cs.getCachedTool(params.Name); tool != nil {
ctx = context.WithValue(ctx, toolContextKey, tool)
}
if cs.usesNewProtocol() {
params = injectRequestMeta(cs, params)
}
return handleSend[*CallToolResult](ctx, methodCallTool, newClientRequest(cs, orZero[Params](params)))
}

Expand All @@ -1050,20 +1180,32 @@ func (cs *ClientSession) SetLoggingLevel(ctx context.Context, params *SetLogging

// ListResources lists the resources that are currently available on the server.
func (cs *ClientSession) ListResources(ctx context.Context, params *ListResourcesParams) (*ListResourcesResult, error) {
if cs.usesNewProtocol() {
params = injectRequestMeta(cs, params)
}
return handleSend[*ListResourcesResult](ctx, methodListResources, newClientRequest(cs, orZero[Params](params)))
}

// ListResourceTemplates lists the resource templates that are currently available on the server.
func (cs *ClientSession) ListResourceTemplates(ctx context.Context, params *ListResourceTemplatesParams) (*ListResourceTemplatesResult, error) {
if cs.usesNewProtocol() {
params = injectRequestMeta(cs, params)
}
return handleSend[*ListResourceTemplatesResult](ctx, methodListResourceTemplates, newClientRequest(cs, orZero[Params](params)))
}

// ReadResource asks the server to read a resource and return its contents.
func (cs *ClientSession) ReadResource(ctx context.Context, params *ReadResourceParams) (*ReadResourceResult, error) {
if cs.usesNewProtocol() {
params = injectRequestMeta(cs, params)
}
return handleSend[*ReadResourceResult](ctx, methodReadResource, newClientRequest(cs, orZero[Params](params)))
}

func (cs *ClientSession) Complete(ctx context.Context, params *CompleteParams) (*CompleteResult, error) {
if cs.usesNewProtocol() {
params = injectRequestMeta(cs, params)
}
return handleSend[*CompleteResult](ctx, methodComplete, newClientRequest(cs, orZero[Params](params)))
}

Expand Down
Loading
Loading