mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
chore(deps): bump libopenapi to 0.40.1; hold mcp-go at 1.1.0 (TASK-3298)
Replaces dependabot #1635, which also moved mcp-go 1.1.0 -> 1.1.1. That release (upstream #977) makes StdioServer.Listen cancel every in-flight request's context when it reads stdin EOF. For `pad mcp serve` fed a piped batch, each dispatcher-backed tool call and resource read then answers `context canceled` instead of its result (measured: 3 of 4 ids failed on a build of #1635, 0 of 4 on main). - go.mod/go.sum: pb33f/libopenapi 0.38.7 -> 0.40.1 (pulls pb33f/jsonpath 0.8.2 -> 0.8.3). Identical to #1635's change minus mcp-go. - .github/dependabot.yml: ignore mcp-go 1.1.1 only, not the dependency. - internal/mcp/stdio_eof_inflight_test.go: pins the behaviour. Three tool calls and one resource read are held inside a blocking dispatcher/fetcher, stdin is closed while all four are in flight, then they are released and every id must answer successfully. Passes 3/3 on mcp-go 1.1.0 and fails 3/3 on 1.1.1 (all four ids "context canceled"), run via a scratch -modfile. Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
This commit is contained in:
@@ -25,6 +25,14 @@ updates:
|
|||||||
commit-message:
|
commit-message:
|
||||||
prefix: "chore(deps)"
|
prefix: "chore(deps)"
|
||||||
include: scope
|
include: scope
|
||||||
|
ignore:
|
||||||
|
# mcp-go v1.1.1 cancels every in-flight stdio request at stdin EOF
|
||||||
|
# (upstream #977), so `pad mcp serve` fed a piped batch answers
|
||||||
|
# `context canceled` for each dispatcher-backed call (TASK-3298).
|
||||||
|
# Only this release is excluded; internal/mcp's
|
||||||
|
# TestStdio_EOFDoesNotCancelInFlightRequests guards the next one.
|
||||||
|
- dependency-name: "github.com/mark3labs/mcp-go"
|
||||||
|
versions: ["1.1.1"]
|
||||||
groups:
|
groups:
|
||||||
go-minor-and-patch:
|
go-minor-and-patch:
|
||||||
applies-to: version-updates
|
applies-to: version-updates
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ require (
|
|||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
github.com/mark3labs/mcp-go v1.1.0
|
github.com/mark3labs/mcp-go v1.1.0
|
||||||
github.com/ory/fosite v0.49.0
|
github.com/ory/fosite v0.49.0
|
||||||
github.com/pb33f/libopenapi v0.38.7
|
github.com/pb33f/libopenapi v0.40.1
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/prometheus/client_golang v1.24.1
|
github.com/prometheus/client_golang v1.24.1
|
||||||
github.com/prometheus/client_model v0.6.3
|
github.com/prometheus/client_model v0.6.3
|
||||||
@@ -98,7 +98,7 @@ require (
|
|||||||
github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe // indirect
|
github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe // indirect
|
||||||
github.com/ory/go-convenience v0.1.0 // indirect
|
github.com/ory/go-convenience v0.1.0 // indirect
|
||||||
github.com/ory/x v0.0.665 // indirect
|
github.com/ory/x v0.0.665 // indirect
|
||||||
github.com/pb33f/jsonpath v0.8.2 // indirect
|
github.com/pb33f/jsonpath v0.8.3 // indirect
|
||||||
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
|
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.0.9 // indirect
|
github.com/pelletier/go-toml/v2 v2.0.9 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
|||||||
@@ -391,10 +391,10 @@ github.com/ory/jsonschema/v3 v3.0.8 h1:Ssdb3eJ4lDZ/+XnGkvQS/te0p+EkolqwTsDOCxr/F
|
|||||||
github.com/ory/jsonschema/v3 v3.0.8/go.mod h1:ZPzqjDkwd3QTnb2Z6PAS+OTvBE2x5i6m25wCGx54W/0=
|
github.com/ory/jsonschema/v3 v3.0.8/go.mod h1:ZPzqjDkwd3QTnb2Z6PAS+OTvBE2x5i6m25wCGx54W/0=
|
||||||
github.com/ory/x v0.0.665 h1:61vv0ObCDSX1vOQYbxBeqDiv4YiPmMT91lYxDaaKX08=
|
github.com/ory/x v0.0.665 h1:61vv0ObCDSX1vOQYbxBeqDiv4YiPmMT91lYxDaaKX08=
|
||||||
github.com/ory/x v0.0.665/go.mod h1:7SCTki3N0De3ZpqlxhxU/94ZrOCfNEnXwVtd0xVt+L8=
|
github.com/ory/x v0.0.665/go.mod h1:7SCTki3N0De3ZpqlxhxU/94ZrOCfNEnXwVtd0xVt+L8=
|
||||||
github.com/pb33f/jsonpath v0.8.2 h1:Ou4C7zjYClBm97dfZjDCjdZGusJoynv/vrtiEKNfj2Y=
|
github.com/pb33f/jsonpath v0.8.3 h1:gdOUYn31vic8iRSSR4I2SEv7UQf2WxLeDcUnDPpGZNE=
|
||||||
github.com/pb33f/jsonpath v0.8.2/go.mod h1:zBV5LJW4OQOPatmQE2QdKpGQJvhDTlE5IEj6ASaRNTo=
|
github.com/pb33f/jsonpath v0.8.3/go.mod h1:zBV5LJW4OQOPatmQE2QdKpGQJvhDTlE5IEj6ASaRNTo=
|
||||||
github.com/pb33f/libopenapi v0.38.7 h1:Q2jfgRPdnU38WW8wQvrX2HEPGiqsxj01PX1BHmAEihc=
|
github.com/pb33f/libopenapi v0.40.1 h1:Ma+2kxag5ox70sSP4dcgGY/4QGZ0VS5Ov8S/z4POyIc=
|
||||||
github.com/pb33f/libopenapi v0.38.7/go.mod h1:naZ03Auhn7i+RJtMv8ck8l7Ag8E2/x2w66j9vsDFL38=
|
github.com/pb33f/libopenapi v0.40.1/go.mod h1:nISN8KLZBliHRwZY0V53JZJtuzI8P52WGfJ6J56z9Tg=
|
||||||
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
|
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
|
||||||
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
|
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
|
||||||
github.com/pb33f/testify v0.1.0 h1:g48/HDU/jn2COspS4nM0scptxiKTJ4DnbX/4ehK6IZ8=
|
github.com/pb33f/testify v0.1.0 h1:g48/HDU/jn2COspS4nM0scptxiKTJ4DnbX/4ehK6IZ8=
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TASK-3298: stdin EOF must not cancel requests that are already in flight.
|
||||||
|
//
|
||||||
|
// `pad mcp serve` is also driven non-interactively: a script pipes a batch of
|
||||||
|
// JSON-RPC requests in and closes stdin, expecting one answer per id. mcp-go
|
||||||
|
// v1.1.1 changed StdioServer.Listen to cancel every in-flight request's
|
||||||
|
// context the moment it reads EOF (upstream #977), so each dispatcher-backed
|
||||||
|
// call answered `server_error: context canceled` instead of its result. That
|
||||||
|
// release is excluded in .github/dependabot.yml; this test is the pin that
|
||||||
|
// keeps any later mcp-go version from bringing the behaviour back unnoticed.
|
||||||
|
//
|
||||||
|
// The calls are held inside the dispatcher and the fetcher until stdin has
|
||||||
|
// been closed, so EOF is guaranteed to arrive while all of them are in
|
||||||
|
// flight: three tools/call (served by mcp-go's worker pool, default size 5)
|
||||||
|
// and one resources/read, sent last because v1.1.0 serves non-tool requests
|
||||||
|
// on the read loop itself. They are released only after EOF has had time to
|
||||||
|
// be read, and every id must then answer successfully.
|
||||||
|
|
||||||
|
// blockingDispatcher holds each call until release is closed, and reports
|
||||||
|
// the context's error instead if the context ends first.
|
||||||
|
type blockingDispatcher struct {
|
||||||
|
entered chan struct{}
|
||||||
|
release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *blockingDispatcher) Dispatch(ctx context.Context, _ []string, _ []string) (*mcp.CallToolResult, error) {
|
||||||
|
d.entered <- struct{}{}
|
||||||
|
select {
|
||||||
|
case <-d.release:
|
||||||
|
return mcp.NewToolResultText("dispatched"), nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type blockingFetcher struct {
|
||||||
|
entered chan struct{}
|
||||||
|
release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *blockingFetcher) Fetch(ctx context.Context, _ []string) (string, error) {
|
||||||
|
f.entered <- struct{}{}
|
||||||
|
select {
|
||||||
|
case <-f.release:
|
||||||
|
return `[{"slug":"docapp","name":"Pad"}]`, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStdio_EOFDoesNotCancelInFlightRequests(t *testing.T) {
|
||||||
|
const toolCalls = 3
|
||||||
|
entered := make(chan struct{}, toolCalls+1)
|
||||||
|
release := make(chan struct{})
|
||||||
|
|
||||||
|
srv := NewServer(Options{Version: "eof-test"})
|
||||||
|
if _, err := RegisterCatalog(srv.MCP(), CatalogOptions{
|
||||||
|
Doc: liveCmdhelpDoc(t),
|
||||||
|
Workspace: NewWorkspaceState("docapp"),
|
||||||
|
Dispatcher: &blockingDispatcher{entered: entered, release: release},
|
||||||
|
PadVersion: "test",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("RegisterCatalog: %v", err)
|
||||||
|
}
|
||||||
|
RegisterResources(srv.MCP(), &blockingFetcher{entered: entered, release: release}, nil)
|
||||||
|
|
||||||
|
stdin, stdinW := io.Pipe()
|
||||||
|
stdoutR, stdout := io.Pipe()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- srv.RunStdio(context.Background(), stdin, stdout)
|
||||||
|
_ = stdout.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Collect every response line until the server closes stdout.
|
||||||
|
type response struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Error *json.RawMessage `json:"error"`
|
||||||
|
Result struct {
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
} `json:"result"`
|
||||||
|
raw string
|
||||||
|
}
|
||||||
|
responses := make(chan []response, 1)
|
||||||
|
go func() {
|
||||||
|
var got []response
|
||||||
|
sc := bufio.NewScanner(stdoutR)
|
||||||
|
sc.Buffer(make([]byte, 1<<20), 1<<24)
|
||||||
|
for sc.Scan() {
|
||||||
|
var r response
|
||||||
|
if json.Unmarshal(sc.Bytes(), &r) == nil {
|
||||||
|
r.raw = sc.Text()
|
||||||
|
got = append(got, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
responses <- got
|
||||||
|
}()
|
||||||
|
|
||||||
|
lines := []string{
|
||||||
|
`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"eof-test","version":"0"}}}`,
|
||||||
|
`{"jsonrpc":"2.0","method":"notifications/initialized"}`,
|
||||||
|
}
|
||||||
|
for i := 0; i < toolCalls; i++ {
|
||||||
|
lines = append(lines, fmt.Sprintf(
|
||||||
|
`{"jsonrpc":"2.0","id":%d,"method":"tools/call","params":{"name":"pad_item","arguments":{"action":"get","ref":"TASK-%d"}}}`,
|
||||||
|
10+i, i+1))
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf(
|
||||||
|
`{"jsonrpc":"2.0","id":20,"method":"resources/read","params":{"uri":%q}}`, WorkspacesURI))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_, _ = io.WriteString(stdinW, strings.Join(lines, "\n")+"\n")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Every call is inside the dispatcher or fetcher before stdin closes.
|
||||||
|
for i := 0; i < toolCalls+1; i++ {
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatalf("only %d of %d calls reached the dispatcher/fetcher", i, toolCalls+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = stdinW.Close()
|
||||||
|
|
||||||
|
// Give the read loop time to reach EOF, then let the calls finish.
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
close(release)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunStdio: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("RunStdio did not return after stdin EOF")
|
||||||
|
}
|
||||||
|
got := <-responses
|
||||||
|
|
||||||
|
byID := map[int]response{}
|
||||||
|
for _, r := range got {
|
||||||
|
byID[r.ID] = r
|
||||||
|
}
|
||||||
|
want := []int{1, 20}
|
||||||
|
for i := 0; i < toolCalls; i++ {
|
||||||
|
want = append(want, 10+i)
|
||||||
|
}
|
||||||
|
for _, id := range want {
|
||||||
|
r, ok := byID[id]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("id %d: no response after stdin EOF", id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Error != nil || r.Result.IsError {
|
||||||
|
t.Errorf("id %d: answered with an error after stdin EOF: %s", id, r.raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user