Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

865 lines
28 KiB
Go

package streamable
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
"github.com/zzet/gortex/internal/daemon"
gortexmcp "github.com/zzet/gortex/internal/mcp"
"go.uber.org/zap"
)
// Header names defined by the MCP 2026 Streamable HTTP transport spec.
// They are case-insensitive on the wire (Go's net/http handles that)
// but are spelled here in the spec's canonical form so log output
// stays grep-friendly.
const (
HeaderSessionID = "Mcp-Session-Id"
HeaderProtocolVersion = "Mcp-Protocol-Version"
)
// DefaultProtocolVersion is what the transport advertises in
// `Mcp-Protocol-Version` when the client did not pin a version on the
// initialize call. Bumped in lockstep with the underlying mcp-go
// library — the 2026-03-26 spec is the current stable line, the
// June-2026 spec will move it forward.
const DefaultProtocolVersion = "2026-03-26"
// Dispatcher is implemented by the in-process MCP server (and the
// daemon's router-aware shim) to turn a JSON-RPC frame into a
// JSON-RPC reply. Returning a nil reply with nil error signals "the
// inbound frame was a notification — write back an HTTP 202 with no
// body" per the spec.
type Dispatcher interface {
Dispatch(ctx context.Context, frame []byte) ([]byte, error)
}
// MCPServerDispatcher wraps an *mcpserver.MCPServer so the transport
// can dispatch frames without growing a hard dependency on it.
// HandleMessage is goroutine-safe; multiple concurrent requests share
// the same server instance, exactly like the stdio and Unix-socket
// transports already do.
type MCPServerDispatcher struct{ Server *mcpserver.MCPServer }
// Dispatch implements Dispatcher.
func (d MCPServerDispatcher) Dispatch(ctx context.Context, frame []byte) ([]byte, error) {
if d.Server == nil {
return nil, errors.New("streamable: MCPServerDispatcher.Server is nil")
}
reply := d.Server.HandleMessage(ctx, json.RawMessage(frame))
if reply == nil {
return nil, nil
}
out, err := json.Marshal(reply)
if err != nil {
return nil, fmt.Errorf("streamable: marshal reply: %w", err)
}
return out, nil
}
// Config bundles every wire-up knob the transport accepts. Every
// field except Dispatcher and Store has a sane zero-value default; in
// the simplest case the standalone `gortex server` invocation passes
// only those two and a logger.
type Config struct {
// Dispatcher executes a JSON-RPC frame and returns the reply.
// Required.
Dispatcher Dispatcher
// Store persists per-session state across requests. Required.
// Pass StatelessStore{} to disable session continuity entirely.
Store SessionStore
// Logger receives structured diagnostic events. Nil falls back
// to zap.NewNop().
Logger *zap.Logger
// Router, when non-nil, gets the first crack at every tools/call
// frame. A remote workspace match short-circuits the dispatch
// and the upstream JSON response is returned verbatim; an
// unresolved or local route falls through to Dispatcher. Pass
// nil for the single-server case (no servers.toml).
Router *daemon.Router
// AllowedOrigins, when non-empty, restricts which Origin
// header values the transport accepts on POST. Defends against
// DNS-rebinding attacks the spec calls out. An empty list
// disables the check (the standalone server defaults to
// localhost-only binding, which provides equivalent protection).
AllowedOrigins []string
// ProtocolVersion is the version advertised in
// `Mcp-Protocol-Version` when the client did not pin one. Empty
// falls back to DefaultProtocolVersion.
ProtocolVersion string
// SessionTTL, when non-zero, sets the keep-alive horizon for
// the in-memory store the transport falls back to when Store
// is left nil. Has no effect when Store is explicitly supplied.
SessionTTL time.Duration
// MaxRequestBytes caps the inbound JSON-RPC body. Zero falls
// back to 4 MiB, generous enough for the largest WorkspaceEdit
// payloads we currently see in simulate_chain calls.
MaxRequestBytes int64
// InitializeHook, when non-nil, is invoked synchronously after
// the transport extracts ClientInfo from a successful
// `initialize` frame. The daemon uses this to propagate the
// authoritative client name into its per-session metadata
// (matches the maybeSnoopInitialize behaviour of the
// stdio-based dispatcher). Errors from the hook are logged but
// do not fail the request — initialize must always succeed
// once the inbound frame was well-formed.
InitializeHook InitializeHook
}
// InitializeHook is the signature the daemon-side adapter implements
// to enrich its per-session bookkeeping with the authoritative
// clientInfo carried in an initialize frame.
type InitializeHook func(ctx context.Context, state *SessionState)
// Transport is an http.Handler exposing a single POST/GET/DELETE
// endpoint speaking the MCP 2026 Streamable HTTP wire format. One
// instance is safe to share across goroutines; the SessionStore is
// the only shared mutable state and is itself goroutine-safe by
// contract.
type Transport struct {
dispatcher Dispatcher
store SessionStore
logger *zap.Logger
router *daemon.Router
allowedOrigins map[string]struct{}
protocolVersion string
maxRequestBytes int64
initializeHook InitializeHook
streamsMu sync.Mutex
streams map[string]*serverStream
}
// New builds a Transport from its Config. Panics when Dispatcher is
// nil — that's a programmer error caught at startup, not an
// operational failure to log and continue past. Store may be left
// nil, in which case a process-local MemoryStore with a 30-minute TTL
// is allocated.
func New(cfg Config) *Transport {
if cfg.Dispatcher == nil {
panic("streamable: Config.Dispatcher is nil")
}
store := cfg.Store
if store == nil {
ttl := cfg.SessionTTL
if ttl <= 0 {
ttl = 30 * time.Minute
}
store = NewMemoryStore(ttl)
}
logger := cfg.Logger
if logger == nil {
logger = zap.NewNop()
}
version := strings.TrimSpace(cfg.ProtocolVersion)
if version == "" {
version = DefaultProtocolVersion
}
maxBytes := cfg.MaxRequestBytes
if maxBytes <= 0 {
maxBytes = 4 << 20
}
allowed := make(map[string]struct{}, len(cfg.AllowedOrigins))
for _, o := range cfg.AllowedOrigins {
o = strings.TrimSpace(o)
if o == "" {
continue
}
allowed[strings.ToLower(o)] = struct{}{}
}
return &Transport{
dispatcher: cfg.Dispatcher,
store: store,
logger: logger,
router: cfg.Router,
allowedOrigins: allowed,
protocolVersion: version,
maxRequestBytes: maxBytes,
initializeHook: cfg.InitializeHook,
streams: make(map[string]*serverStream),
}
}
// Store exposes the session store so callers can wire SSE notifiers,
// metrics, or admin tools onto it without growing the Transport
// surface.
func (t *Transport) Store() SessionStore { return t.store }
// ServeHTTP implements http.Handler. The spec mandates that the
// transport surface ALL three verbs at the same path; routes split by
// method to keep the dispatch logic in dedicated helpers.
func (t *Transport) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
t.handlePost(w, r)
case http.MethodGet:
t.handleGet(w, r)
case http.MethodDelete:
t.handleDelete(w, r)
case http.MethodOptions:
// Pre-flight CORS support — the spec does not mandate it
// but every modern HTTP client (browsers, Cursor, VS Code
// extensions) sends one. Replying 204 with the methods we
// support keeps them from bailing on the first call.
w.Header().Set("Allow", "POST, GET, DELETE, OPTIONS")
w.WriteHeader(http.StatusNoContent)
default:
w.Header().Set("Allow", "POST, GET, DELETE, OPTIONS")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// originAllowed enforces the Origin allowlist when one is configured.
// Same-origin requests typically omit Origin entirely; a missing
// header is therefore treated as allowed. When AllowedOrigins is
// empty (the default for localhost binds) the check is skipped.
func (t *Transport) originAllowed(r *http.Request) bool {
if len(t.allowedOrigins) == 0 {
return true
}
origin := strings.ToLower(strings.TrimSpace(r.Header.Get("Origin")))
if origin == "" {
return true
}
_, ok := t.allowedOrigins[origin]
return ok
}
// handlePost is the workhorse: parses one or more JSON-RPC frames
// from the body, resolves or mints a session, dispatches each frame
// through the configured Dispatcher (or the multi-server Router), and
// serializes the replies back as either a single JSON object or a
// JSON array, matching the JSON-RPC 2.0 batch convention.
func (t *Transport) handlePost(w http.ResponseWriter, r *http.Request) {
if !t.originAllowed(r) {
writeJSONRPCError(w, http.StatusForbidden, nil, -32600,
"origin not allowed")
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, t.maxRequestBytes))
if err != nil {
writeJSONRPCError(w, http.StatusBadRequest, nil, -32700,
"failed to read request body")
return
}
if len(body) == 0 {
writeJSONRPCError(w, http.StatusBadRequest, nil, -32700,
"empty request body")
return
}
frames, batched, err := splitJSONRPC(body)
if err != nil {
writeJSONRPCError(w, http.StatusBadRequest, nil, -32700, err.Error())
return
}
// Resolve the session once for the whole request — every frame
// in a batch shares it, which is also how stdio and SSE
// transports behave.
sessionID := strings.TrimSpace(r.Header.Get(HeaderSessionID))
state, _ := t.store.Get(sessionID)
// Set the protocol version response header up-front so even
// error paths carry it. Clients use it to confirm they're
// talking to a compatible server before bothering with the
// JSON-RPC layer.
clientVersion := strings.TrimSpace(r.Header.Get(HeaderProtocolVersion))
if clientVersion != "" {
w.Header().Set(HeaderProtocolVersion, clientVersion)
} else {
w.Header().Set(HeaderProtocolVersion, t.protocolVersion)
}
// Dispatch each frame in order and collect replies. We never
// fan-out across goroutines: JSON-RPC ordering matters when
// notifications mutate session state mid-batch.
replies := make([]json.RawMessage, 0, len(frames))
for _, frame := range frames {
replyBytes, status, err := t.dispatchFrame(r, &state, sessionID, frame)
if err != nil {
t.logger.Warn("streamable: dispatch failed",
zap.String("session_id", sessionID), zap.Error(err))
id, _ := peekJSONRPCID(frame)
replyBytes = jsonRPCErrorBytes(id, -32603, err.Error())
}
if status != 0 && status != http.StatusOK {
// A remote upstream returned a non-2xx; surface that
// to the client as a JSON-RPC error frame so the
// batch shape stays intact.
if len(replyBytes) == 0 {
id, _ := peekJSONRPCID(frame)
replyBytes = jsonRPCErrorBytes(id, -32603,
fmt.Sprintf("upstream status %d", status))
}
}
if len(replyBytes) == 0 {
// Notifications have no reply; the frame still
// counts so a batch retains its slot ordering on
// the response side via skip semantics.
continue
}
replies = append(replies, replyBytes)
}
// Mint or refresh the session id on the response. The spec
// requires the header on every response, not just initialize,
// so clients that drop the cookie between calls can rebuild it.
if id := state.ID; id != "" {
w.Header().Set(HeaderSessionID, id)
}
// All-notifications batch — respond 202 Accepted, empty body.
if len(replies) == 0 {
w.WriteHeader(http.StatusAccepted)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if batched {
// Reassemble as a JSON array — encoding/json sees the
// pre-marshalled bytes verbatim via json.RawMessage.
_ = json.NewEncoder(w).Encode(replies)
return
}
_, _ = w.Write(replies[0])
_, _ = w.Write([]byte("\n"))
}
// dispatchFrame runs the Router / InitializeHook / Dispatcher chain
// for a single JSON-RPC frame and returns its (possibly empty) reply
// plus the upstream HTTP status when the router proxied the call.
// state is updated in-place when the frame is an initialize request
// or carries clientInfo metadata worth persisting.
func (t *Transport) dispatchFrame(r *http.Request, state *SessionState, requestedID string, frame []byte) ([]byte, int, error) {
method, _ := peekJSONRPCMethod(frame)
switch method {
case "initialize":
// Mint a session even when the client claimed an ID the
// store no longer knows about — the spec mandates that
// initialize always produces a fresh session id on the
// response.
if state.ID != "" {
t.store.Delete(state.ID)
*state = SessionState{}
}
fresh := newInitializeState(frame, r)
id, err := t.store.Create(fresh)
if err != nil {
return nil, 0, fmt.Errorf("create session: %w", err)
}
fresh.ID = id
*state = fresh
if t.initializeHook != nil {
t.initializeHook(r.Context(), state)
_ = t.store.Update(*state)
}
// Fall through to local dispatch — the initialize
// response is what tells the client the server's
// capabilities, and the underlying mcp-go server owns
// that payload.
return t.localDispatch(r, *state, frame)
case "notifications/initialized":
if state.ID != "" {
state.Initialized = true
_ = t.store.Update(*state)
}
return t.localDispatch(r, *state, frame)
}
// For non-initialize calls, reject when the client claimed an
// ID we don't know — protects against stale-session race when
// the store evicted while the client was idle. The spec calls
// for HTTP 404 + Mcp-Session-Id absent on the reply.
if requestedID != "" && state.ID == "" {
id, _ := peekJSONRPCID(frame)
return jsonRPCErrorBytes(id, -32001,
fmt.Sprintf("session %q not found", requestedID)), 0, nil
}
// Tool-call frames go through the multi-server router first.
// Other frames (tools/list, ping, resources/*, prompts/*) flow
// to the in-process MCP server — federating them would change
// semantics.
if method == "tools/call" && t.router != nil {
if out, status, ok := t.tryRouteToolCall(r, *state, frame); ok {
return out, status, nil
}
}
return t.localDispatch(r, *state, frame)
}
// localDispatch attaches the session id / cwd / workspace to the
// context and hands the frame to the configured Dispatcher.
func (t *Transport) localDispatch(r *http.Request, state SessionState, frame []byte) ([]byte, int, error) {
ctx := r.Context()
if state.ID != "" {
ctx = gortexmcp.WithSessionID(ctx, state.ID)
}
cwd := state.CWD
if cwd == "" {
cwd = strings.TrimSpace(r.Header.Get("X-Gortex-Cwd"))
}
if cwd != "" {
ctx = gortexmcp.WithSessionCWD(ctx, cwd)
}
reply, err := t.dispatcher.Dispatch(ctx, frame)
if err != nil {
return nil, 0, err
}
return reply, 0, nil
}
// tryRouteToolCall mirrors the daemon dispatcher's tryProxyToolCall:
// peek at the inbound tools/call arguments for an explicit workspace
// scope or a cwd that resolves to a remote server in the multi-server
// roster, and proxy the call there. A return value of (_, _, false)
// means "fall through to local dispatch".
func (t *Transport) tryRouteToolCall(r *http.Request, state SessionState, frame []byte) ([]byte, int, bool) {
// Decode the JSON-RPC envelope keeping the inbound `arguments`
// object as raw bytes — we MUST forward every caller-supplied key
// (e.g. `query`, `limit`, etc.) to the downstream executor, not
// just the workspace+cwd peek fields. A previous version
// re-marshalled only the typed peek struct, which silently
// stripped every other argument and made every router-routed tool
// call see an empty args map ("X is required" failures). Mirror
// the daemon dispatcher's tryProxyToolCall: peek workspace+cwd
// without dropping the rest.
var envelope struct {
ID json.RawMessage `json:"id"`
Params struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"params"`
}
if err := json.Unmarshal(frame, &envelope); err != nil {
return nil, 0, false
}
if envelope.Params.Name == "" {
return nil, 0, false
}
// Second decode is only used to peek the routing hints.
var peek struct {
Workspace string `json:"workspace"`
Cwd string `json:"cwd"`
}
if len(envelope.Params.Arguments) > 0 {
_ = json.Unmarshal(envelope.Params.Arguments, &peek)
}
scope := peek.Workspace
if scope == "" {
scope = state.Workspace
}
cwd := peek.Cwd
if cwd == "" {
cwd = state.CWD
}
if cwd == "" {
cwd = strings.TrimSpace(r.Header.Get("X-Gortex-Cwd"))
}
// Wrap the original raw arguments under `{"arguments": {...}}` so
// the local executor's nested-arguments unmarshal path (see
// cmd/gortex/server_router.go newLocalToolExecutor) finds them.
// This matches cmd/gortex/daemon_mcp.go:tryProxyToolCall exactly.
rawArgs := envelope.Params.Arguments
if len(rawArgs) == 0 {
rawArgs = json.RawMessage(`{}`)
}
body, err := json.Marshal(map[string]json.RawMessage{"arguments": rawArgs})
if err != nil {
return nil, 0, false
}
decision := daemon.NewProxyDecision(func() *daemon.Router { return t.router })
outcome := decision.Decide(r.Context(), daemon.RouteInputs{
ToolName: envelope.Params.Name,
Body: body,
Cwd: cwd,
Scope: scope,
}, nil)
if !outcome.Proxied {
// Local route — let the in-process MCP server handle it.
return nil, 0, false
}
out, status := outcome.Out, outcome.Status
// The router returned an /v1/tools/<name>-shaped response;
// translate it into a JSON-RPC `result` frame so the client
// sees the same envelope every other tool/call produces.
id, _ := peekJSONRPCID(frame)
wrapped := wrapToolResultAsJSONRPC(id, out, status)
return wrapped, status, true
}
// handleGet opens an SSE stream the server can use to deliver
// notifications (progress updates, diagnostics subscriptions, sampling
// requests) to the client without waiting for the next POST. The
// stream is bound to the session ID supplied in the header.
func (t *Transport) handleGet(w http.ResponseWriter, r *http.Request) {
if !t.originAllowed(r) {
http.Error(w, "origin not allowed", http.StatusForbidden)
return
}
sid := strings.TrimSpace(r.Header.Get(HeaderSessionID))
if sid == "" {
http.Error(w, "missing Mcp-Session-Id", http.StatusBadRequest)
return
}
state, ok := t.store.Get(sid)
if !ok {
http.Error(w, "unknown session", http.StatusNotFound)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set(HeaderSessionID, state.ID)
w.WriteHeader(http.StatusOK)
flusher.Flush()
stream := t.attachStream(state.ID)
defer t.detachStream(state.ID, stream)
keepalive := time.NewTicker(15 * time.Second)
defer keepalive.Stop()
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case <-stream.done:
return
case msg, ok := <-stream.ch:
if !ok {
return
}
t.writeSSE(w, flusher, msg)
case <-keepalive.C:
_, _ = io.WriteString(w, ": keepalive\n\n")
flusher.Flush()
}
}
}
// handleDelete is the spec's session-termination verb. Idempotent —
// deleting an unknown session returns 204 just like deleting a known
// one. The transport also tears down any in-flight SSE stream so the
// client's GET unblocks immediately.
func (t *Transport) handleDelete(w http.ResponseWriter, r *http.Request) {
if !t.originAllowed(r) {
http.Error(w, "origin not allowed", http.StatusForbidden)
return
}
sid := strings.TrimSpace(r.Header.Get(HeaderSessionID))
if sid != "" {
t.store.Delete(sid)
t.closeStream(sid)
}
w.WriteHeader(http.StatusNoContent)
}
// Push sends a server-initiated JSON-RPC message to the SSE stream
// for the given session id (if one is open). It is non-blocking: when
// no listener is attached or the buffer is full the message is
// dropped and the call returns false. Callers wire this from the
// notification bus (diagnostics, progress) the in-process MCP server
// already owns.
func (t *Transport) Push(sessionID string, frame json.RawMessage) bool {
t.streamsMu.Lock()
stream, ok := t.streams[sessionID]
t.streamsMu.Unlock()
if !ok {
return false
}
select {
case stream.ch <- frame:
return true
default:
return false
}
}
// serverStream is the per-session SSE listener state. Buffered so a
// burst of progress notifications doesn't block the in-process MCP
// server's notification loop.
type serverStream struct {
ch chan json.RawMessage
done chan struct{}
}
func (t *Transport) attachStream(sid string) *serverStream {
stream := &serverStream{
ch: make(chan json.RawMessage, 32),
done: make(chan struct{}),
}
t.streamsMu.Lock()
if existing, ok := t.streams[sid]; ok {
// Only one GET per session per spec — close the previous
// stream so the new GET sees clean delivery semantics.
close(existing.done)
}
t.streams[sid] = stream
t.streamsMu.Unlock()
return stream
}
func (t *Transport) detachStream(sid string, stream *serverStream) {
t.streamsMu.Lock()
defer t.streamsMu.Unlock()
if current, ok := t.streams[sid]; ok && current == stream {
delete(t.streams, sid)
}
}
func (t *Transport) closeStream(sid string) {
t.streamsMu.Lock()
defer t.streamsMu.Unlock()
if stream, ok := t.streams[sid]; ok {
close(stream.done)
delete(t.streams, sid)
}
}
func (t *Transport) writeSSE(w io.Writer, flusher http.Flusher, frame json.RawMessage) {
// Each SSE message is one or more "data:" lines followed by a
// blank line. JSON-RPC frames are single-line by convention so
// one data line is enough.
_, _ = io.WriteString(w, "event: message\ndata: ")
_, _ = w.Write(frame)
_, _ = io.WriteString(w, "\n\n")
flusher.Flush()
}
// --- helpers --------------------------------------------------------
// splitJSONRPC accepts the request body and returns one frame per
// JSON-RPC message, plus a boolean indicating whether the original
// body was a batch (JSON array) or a single object. Each returned
// frame is a complete, self-contained JSON document the dispatcher
// can hand to mcp-go's HandleMessage verbatim.
func splitJSONRPC(body []byte) ([][]byte, bool, error) {
trimmed := skipWhitespace(body)
if len(trimmed) == 0 {
return nil, false, errors.New("empty request body")
}
switch trimmed[0] {
case '{':
return [][]byte{body}, false, nil
case '[':
var raw []json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return nil, false, fmt.Errorf("parse batch: %w", err)
}
if len(raw) == 0 {
return nil, true, errors.New("empty batch")
}
frames := make([][]byte, 0, len(raw))
for _, r := range raw {
frames = append(frames, []byte(r))
}
return frames, true, nil
default:
return nil, false, fmt.Errorf("expected JSON object or array, got %q", trimmed[0])
}
}
func skipWhitespace(b []byte) []byte {
for i, c := range b {
switch c {
case ' ', '\t', '\n', '\r':
continue
default:
return b[i:]
}
}
return nil
}
// peekJSONRPCMethod extracts the `method` string from a frame
// without unmarshalling the entire envelope. Returns ("", false) when
// the frame is not a JSON-RPC request/notification.
func peekJSONRPCMethod(frame []byte) (string, bool) {
var env struct {
Method string `json:"method"`
}
if err := json.Unmarshal(frame, &env); err != nil {
return "", false
}
return env.Method, env.Method != ""
}
// peekJSONRPCID extracts the `id` raw value (number, string, or
// null) from a frame so error responses can echo it back unchanged.
func peekJSONRPCID(frame []byte) (json.RawMessage, bool) {
var env struct {
ID json.RawMessage `json:"id"`
}
if err := json.Unmarshal(frame, &env); err != nil {
return nil, false
}
if len(env.ID) == 0 {
return nil, false
}
return env.ID, true
}
// jsonRPCErrorBytes returns a marshalled JSON-RPC 2.0 error envelope
// suitable for inclusion in a batch reply. The id may be nil for
// requests where parsing failed before the id could be recovered.
func jsonRPCErrorBytes(id json.RawMessage, code int, message string) []byte {
env := struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Error struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}{
JSONRPC: "2.0",
ID: id,
}
env.Error.Code = code
env.Error.Message = message
out, _ := json.Marshal(env)
return out
}
// writeJSONRPCError writes a top-level error reply for failures that
// happen before per-frame dispatch (body parse errors, origin
// rejection, …). It always sets Content-Type to application/json so
// the client doesn't have to guess.
func writeJSONRPCError(w http.ResponseWriter, status int, id json.RawMessage, code int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(jsonRPCErrorBytes(id, code, message))
_, _ = w.Write([]byte("\n"))
}
// wrapToolResultAsJSONRPC takes the HTTP body the router proxied
// from a remote server's /v1/tools/<name> endpoint and re-emits it as
// the JSON-RPC `result` frame mcp-go's HandleMessage would have
// produced locally. The remote returns a `ToolResponse` shape with
// `content`/`isError` fields — those are exactly the fields a
// `mcp.CallToolResult` carries, so the rewrap is a structural map.
func wrapToolResultAsJSONRPC(id json.RawMessage, upstream []byte, status int) []byte {
if status >= 400 || len(upstream) == 0 {
msg := "remote tool call failed"
if status >= 400 {
msg = fmt.Sprintf("remote tool call failed (HTTP %s)", strconv.Itoa(status))
}
return jsonRPCErrorBytes(id, -32603, msg)
}
// Try to parse as a {content, isError} object first; fall back
// to opaque passthrough so a future router that returns a
// different shape still produces a JSON-RPC-shaped frame.
var toolReply struct {
Content []mcp.TextContent `json:"content"`
IsError bool `json:"isError"`
}
if err := json.Unmarshal(upstream, &toolReply); err == nil && len(toolReply.Content) > 0 {
result := struct {
Content []map[string]string `json:"content"`
IsError bool `json:"isError,omitempty"`
}{
IsError: toolReply.IsError,
}
for _, c := range toolReply.Content {
result.Content = append(result.Content, map[string]string{
"type": "text",
"text": c.Text,
})
}
env := struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result any `json:"result"`
}{JSONRPC: "2.0", ID: id, Result: result}
out, _ := json.Marshal(env)
return out
}
env := struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result json.RawMessage `json:"result"`
}{JSONRPC: "2.0", ID: id, Result: upstream}
out, _ := json.Marshal(env)
return out
}
// newInitializeState extracts everything we can about the client from
// an initialize frame: declared protocol version, clientInfo, raw
// capabilities map, and the optional `_meta.cwd` / `_meta.workspace`
// hints the proxy injects when it dials in on behalf of a CLI user.
func newInitializeState(frame []byte, r *http.Request) SessionState {
var env struct {
Params struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities json.RawMessage `json:"capabilities"`
ClientInfo struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"clientInfo"`
Meta struct {
CWD string `json:"cwd"`
Workspace string `json:"workspace"`
} `json:"_meta"`
} `json:"params"`
}
_ = json.Unmarshal(frame, &env)
cwd := env.Params.Meta.CWD
if cwd == "" {
cwd = strings.TrimSpace(r.Header.Get("X-Gortex-Cwd"))
}
workspace := env.Params.Meta.Workspace
if workspace == "" {
workspace = strings.TrimSpace(r.Header.Get("X-Gortex-Workspace"))
}
// Persist the full params block so an external store can
// replay the entire initialize call onto a fresh worker.
var rawParams json.RawMessage
var envRaw struct {
Params json.RawMessage `json:"params"`
}
if err := json.Unmarshal(frame, &envRaw); err == nil {
rawParams = envRaw.Params
}
return SessionState{
ProtocolVersion: env.Params.ProtocolVersion,
ClientName: env.Params.ClientInfo.Name,
ClientVersion: env.Params.ClientInfo.Version,
Capabilities: env.Params.Capabilities,
InitParams: rawParams,
CWD: cwd,
Workspace: workspace,
}
}