mcp: bound and abandon transport reads
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.
This commit is contained in:
+41
-11
@@ -35,13 +35,14 @@ type PostResponse struct {
|
||||
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) *httpTransport {
|
||||
return &httpTransport{poster: post, url: endpoint}
|
||||
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) {
|
||||
@@ -49,7 +50,7 @@ func (t *httpTransport) Call(ctx context.Context, req *rpcRequest) (*rpcResponse
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
frame, err := decodeFrame(body)
|
||||
frame, err := decodeFrame(body, req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -57,6 +58,18 @@ func (t *httpTransport) Call(ctx context.Context, req *rpcRequest) (*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
|
||||
}
|
||||
|
||||
@@ -71,7 +84,14 @@ func (t *httpTransport) send(ctx context.Context, req *rpcRequest) ([]byte, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hdr := map[string]string{"Accept": "application/json, text/event-stream"}
|
||||
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
|
||||
@@ -114,9 +134,10 @@ func headerGet(h map[string]string, key string) string {
|
||||
}
|
||||
|
||||
// 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, which is the
|
||||
// response — earlier frames on the stream are progress notifications.
|
||||
func decodeFrame(body []byte) ([]byte, error) {
|
||||
// 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")
|
||||
@@ -136,19 +157,28 @@ func decodeFrame(body []byte) ([]byte, error) {
|
||||
if payload == "" {
|
||||
continue
|
||||
}
|
||||
var probe map[string]json.RawMessage
|
||||
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 _, isResp := probe["id"]; isResp {
|
||||
last = []byte(payload)
|
||||
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, errors.New("mcp: no JSON-RPC response in event stream")
|
||||
return nil, fmt.Errorf("mcp: no JSON-RPC response for id %d in event stream", id)
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user