87d03cf8c6
The stdio reader ran inline under the transport lock, and bufio never observes a context. A server that accepted a request and then wrote nothing held that lock forever. alive() takes the same lock and Refresh calls alive() while holding the manager lock, so one mute python server wedged Tools, Status and every Call, including turns that touch no MCP tool at all. The read now runs on its own goroutine feeding a channel, the call selects on the context, and a call that gives up drops the connection so the manager re-dials. The frame bound was measured after the line had been assembled, which is not a bound. A server emitting 500 MB with no newline had all 500 MB in mavend before the check could reject it, which on the deploy target is an OOM kill of the core daemon. The scanner's own buffer limit enforces it now. The HTTP transport never checked the response id. A server request sent mid-stream, sampling/createMessage or roots/list, unmarshalled into a response with neither result nor error, so the call reported success with an empty string. The act was logged as done and the tool never ran. The id must match and the frame must carry a result or an error. Found in review of #70.
185 lines
5.3 KiB
Go
185 lines
5.3 KiB
Go
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
|
|
}
|