package mcp import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "strings" "sync" ) // Poster is the HTTP seam: internal/webfetch.Fetcher satisfies it. The // transport takes it as an interface so a test can serve a fake without a // listener, and so that the ONLY implementation wired in production is the // guarded fetcher — an MCP endpoint cannot get a bare http.Client this way. type Poster interface { Post(ctx context.Context, rawURL, contentType string, body []byte, hdr map[string]string) (*PostResponse, error) } // PostResponse is the shape webfetch returns, restated here so this package // does not depend on it structurally. type PostResponse struct { Status int ContentType string Body []byte Header map[string]string } // httpTransport speaks streamable HTTP: every request is a POST to one // endpoint, and the reply is either a JSON object or a text/event-stream frame // carrying one. Both are accepted — servers pick per response, and the two the // LAN runs disagree about which. type httpTransport struct { poster Poster url string extra map[string]string // static headers, e.g. an Authorization bearer mu sync.Mutex session string // Mcp-Session-Id, echoed back when the server issues one } func newHTTPTransport(post Poster, endpoint string, extra map[string]string) *httpTransport { return &httpTransport{poster: post, url: endpoint, extra: extra} } func (t *httpTransport) Call(ctx context.Context, req *rpcRequest) (*rpcResponse, error) { body, err := t.send(ctx, req) if err != nil { return nil, err } frame, err := decodeFrame(body, req.ID) if err != nil { return nil, err } var resp rpcResponse if err := json.Unmarshal(frame, &resp); err != nil { return nil, fmt.Errorf("mcp: decode response: %w", err) } // The id check the stdio transport already did. Without it a server that // sends a request of its own (sampling/createMessage, roots/list) mid-stream // has that request accepted as the answer: it unmarshals into an rpcResponse // with neither result nor error, and the call reports success with nothing // in it. An empty string and no error is the one answer that lies — the act // is logged as done and the tool never ran. if resp.ID == nil || *resp.ID != req.ID { return nil, fmt.Errorf("mcp: response id mismatch (wanted %d)", req.ID) } if resp.Error == nil && len(resp.Result) == 0 { return nil, errors.New("mcp: response carries neither result nor error") } return &resp, nil } func (t *httpTransport) Notify(ctx context.Context, method string, params any) error { _, err := t.send(ctx, &rpcRequest{JSONRPC: "2.0", Method: method, Params: params}) return err } func (t *httpTransport) send(ctx context.Context, req *rpcRequest) ([]byte, error) { req.JSONRPC = "2.0" raw, err := json.Marshal(req) if err != nil { return nil, err } hdr := map[string]string{} // Configured headers first, so nothing here can be overwritten by them: // a real remote server needs a bearer token, and the Vikunja one on // loopback is only reachable without one because it is unauthenticated. for k, v := range t.extra { hdr[k] = v } hdr["Accept"] = "application/json, text/event-stream" t.mu.Lock() if t.session != "" { hdr["Mcp-Session-Id"] = t.session } t.mu.Unlock() resp, err := t.poster.Post(ctx, t.url, "application/json", raw, hdr) if err != nil { return nil, err } if sid := headerGet(resp.Header, "Mcp-Session-Id"); sid != "" { t.mu.Lock() t.session = sid t.mu.Unlock() } return resp.Body, nil } func (t *httpTransport) Close() error { t.mu.Lock() t.session = "" t.mu.Unlock() return nil } func headerGet(h map[string]string, key string) string { if h == nil { return "" } if v, ok := h[key]; ok { return v } lower := strings.ToLower(key) for k, v := range h { if strings.ToLower(k) == lower { return v } } return "" } // decodeFrame pulls the JSON object out of a body that is either raw JSON or // SSE. For SSE we take the last data: payload that parses AND carries our own // id with a result or an error in it. Matching on the presence of an "id" key // alone is not enough: a JSON-RPC request from the server has one too. func decodeFrame(body []byte, id int64) ([]byte, error) { trimmed := bytes.TrimSpace(body) if len(trimmed) == 0 { return nil, errors.New("mcp: empty response body") } if trimmed[0] == '{' || trimmed[0] == '[' { return trimmed, nil } var last []byte sc := bufio.NewScanner(bytes.NewReader(trimmed)) sc.Buffer(make([]byte, 0, 64<<10), maxLine) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if !strings.HasPrefix(line, "data:") { continue } payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) if payload == "" { continue } var probe struct { ID *int64 `json:"id"` Result json.RawMessage `json:"result"` Error json.RawMessage `json:"error"` Method string `json:"method"` } if json.Unmarshal([]byte(payload), &probe) != nil { continue } if probe.Method != "" || probe.ID == nil || *probe.ID != id { continue } if len(probe.Result) == 0 && len(probe.Error) == 0 { continue } last = []byte(payload) } if err := sc.Err(); err != nil { return nil, fmt.Errorf("mcp: read event stream: %w", err) } if last == nil { return nil, fmt.Errorf("mcp: no JSON-RPC response for id %d in event stream", id) } return last, nil }