From 1cf235f63b374258a7c3ea1b89e819a17fb99b17 Mon Sep 17 00:00:00 2001 From: Junjun Zhang Date: Fri, 18 Sep 2026 11:10:04 +0800 Subject: [PATCH 1/2] feat(mcp): client-side MCP Tasks protocol (SEP-1686) Implement the asynchronous task execution protocol from protocol revision 2025-11-25: - internal/mcp/tasks.go: Task descriptor types (5-state lifecycle), CallToolAsTask (task-augmented tools/call with poll-to-terminal + tasks/result fetch), GetTask/TaskResult/CancelTask/ListTasks, and HasTasks capability gate. Poll loop honors the server pollInterval hint clamped to [200ms, 10s] with a 10-minute total budget; the state machine is injectable (awaitTaskLoop) for transport-free tests. - client.go: CallToolParams.Task request option, ServerCaps.Tasks / ClientCaps.Tasks capability fields, test-only poll override hook. - discover.go: declare client tasks support unconditionally so it flows through both legacy initialize and the modern _meta envelope. - mrtr.go: pass resultType "task" envelopes through to the caller instead of rejecting them as unrecognized results. - tasks_test.go: envelope discrimination, interval clamping, full state-machine transitions (working->completed, input_required, unknown status, context cancel), capability declaration round-trip. - docs/guide/mcp.md: document the new protocol support. Co-Authored-By: ggcode Co-Authored-By: ggcode --- docs/guide/mcp.md | 11 ++ internal/mcp/client.go | 15 +- internal/mcp/discover.go | 5 + internal/mcp/mrtr.go | 7 + internal/mcp/tasks.go | 315 +++++++++++++++++++++++++++++++++++++ internal/mcp/tasks_test.go | 143 +++++++++++++++++ 6 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 internal/mcp/tasks.go create mode 100644 internal/mcp/tasks_test.go diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 87715b3f3..a160b7c57 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -160,6 +160,17 @@ ggcode handles these requests by routing them through the same `ask_user` intera No configuration is needed — elicitation is enabled automatically when an interactive session is active. +## MCP Tasks (Asynchronous Tool Execution, SEP-1686) + +Since protocol revision 2025-11-25, MCP servers may execute tool calls asynchronously. When ggcode sends a `tools/call` with the `task` request option, a compliant server can answer immediately with a *task descriptor* (`resultType: "task"`) instead of blocking until the tool finishes. + +ggcode's client implements the full task protocol: +- `CallToolAsTask` sends the task-augmented call, then polls `tasks/get` until the task reaches a terminal state, and fetches the final tool result via `tasks/result`. The server's `pollInterval` hint is honored, clamped to [200ms, 10s], and the total polling budget is capped at 10 minutes so a stuck server cannot hang a call forever. +- `ListTasks`, `GetTask`, `CancelTask`, and `HasTasks` expose task management; listing is gated on the server's advertised `tasks` capability (uncapable servers get an empty list, never an error). +- During initialize, ggcode declares client `tasks` support in both the legacy handshake and the modern per-request `_meta` envelope. + +A task that ends `failed` or `cancelled` surfaces as an error carrying the server's `statusMessage`. Tasks that pause in `input_required` are reported to the caller rather than auto-resolved, since the interactive flow requires user-driven input. + ## Subscription Streams (MCP 2026-07-28) Protocol revision 2026-07-28 added correlated notification streams: a client may open a subscription with the `subscriptions/listen` request, and every notification the server sends on that stream carries a `_meta` field binding it to the subscription. This closes a long-standing ambiguity — when an agent talks to several MCP servers concurrently, a bare `notifications/tools/list_changed` cannot be attributed to a specific connection with certainty. diff --git a/internal/mcp/client.go b/internal/mcp/client.go index ef81f4868..62439bd04 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -65,6 +65,9 @@ type Client struct { modernVersion string modernServerInfo *Implementation legacyServerInfo Implementation + // taskPollOverride replaces the SEP-1686 poll sleep in tests only; + // always zero in production. + taskPollOverride time.Duration mu sync.Mutex stderrMu sync.RWMutex stderrBuf strings.Builder @@ -2999,6 +3002,9 @@ type ClientCaps struct { } `json:"roots,omitempty"` Sampling *SamplingCapability `json:"sampling,omitempty"` Elicitation *ElicitationCapability `json:"elicitation,omitempty"` + // Tasks declares SEP-1686 task protocol support: the client implements + // tasks/get, tasks/list, tasks/cancel and tasks/result handling. + Tasks *struct{} `json:"tasks,omitempty"` } // ElicitationCapability is the initialize capability object for elicitation @@ -3039,7 +3045,10 @@ type ServerCaps struct { Tools *ToolsCapability `json:"tools,omitempty"` Resources *ResourcesCapability `json:"resources,omitempty"` Prompts *PromptsCapability `json:"prompts,omitempty"` - Logging *struct{} `json:"logging,omitempty"` + // Tasks is the SEP-1686 server capability; presence gates task-augmented + // tool calls and the task management methods. + Tasks *TasksCapability `json:"tasks,omitempty"` + Logging *struct{} `json:"logging,omitempty"` // Completions mirrors the MCP completion capability key. Per spec // (2025-06-18, "Completion": Capabilities) servers that support argument // autocompletion declare `{"capabilities": {"completions": {}}}` — the @@ -3198,6 +3207,10 @@ type CallToolParams struct { // MRTR retry fields; see GetPromptParams. InputResponses map[string]json.RawMessage `json:"inputResponses,omitempty"` RequestState string `json:"requestState,omitempty"` + // Task is the SEP-1686 task request option: when set, the server may + // answer with a task descriptor (resultType "task") instead of the + // tool result; see CallToolAsTask. + Task TaskRequestOptions `json:"task,omitempty"` } type CallToolResult struct { diff --git a/internal/mcp/discover.go b/internal/mcp/discover.go index 32042c385..f16839274 100644 --- a/internal/mcp/discover.go +++ b/internal/mcp/discover.go @@ -243,6 +243,11 @@ func (c *Client) clientCapsLocked() ClientCaps { // consent-gated out-of-band handoff (it never auto-opens URLs). caps.Elicitation = &ElicitationCapability{Form: &struct{}{}, URL: &struct{}{}} } + // SEP-1686: the client always implements the task management methods + // (tasks/get, tasks/list, tasks/cancel, tasks/result), so declare the + // tasks capability unconditionally - it flows through both the legacy + // initialize params and the modern per-request _meta envelope. + caps.Tasks = &struct{}{} return caps } diff --git a/internal/mcp/mrtr.go b/internal/mcp/mrtr.go index 042f36952..e25793275 100644 --- a/internal/mcp/mrtr.go +++ b/internal/mcp/mrtr.go @@ -113,6 +113,13 @@ func (c *Client) mrtrLoop(ctx context.Context, method string, params mrtrRetryPa switch env.ResultType { case "", ResultTypeComplete: return json.Unmarshal(raw, out) + case ResultTypeTask: + // SEP-1686 task descriptor: pass the raw envelope through so the + // caller (CallToolAsTask) can run the poll/result protocol. Only + // task-augmented requests can legitimately receive this shape; + // non-task callers decode it into their result type and surface + // the mismatch themselves. + return json.Unmarshal(raw, out) case ResultTypeInputRequired: if round >= maxMRTRRoundTrips { return fmt.Errorf("mcp[%s]: %s exceeded %d input_required round trips (MRTR loop guard)", c.name, method, maxMRTRRoundTrips) diff --git a/internal/mcp/tasks.go b/internal/mcp/tasks.go new file mode 100644 index 000000000..c48613c78 --- /dev/null +++ b/internal/mcp/tasks.go @@ -0,0 +1,315 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/topcheer/ggcode/internal/debug" +) + +// MCP Tasks (SEP-1686, protocol revision 2025-11-25 / ext-tasks draft): +// asynchronous task execution. A compliant server may answer a task-augmented +// tools/call (params.task request option) not with the tool result but with a +// task descriptor: a flat result carrying resultType "task" plus the Task +// fields (taskId, status, ...). The client then polls tasks/get until the +// task reaches a terminal state and fetches the actual tool result with +// tasks/result. +// +// Task state machine (5 states): +// +// working -> completed | failed | cancelled +// working -> input_required -> (client resolves) -> working +// terminal states are final. +// +// Notes: +// - The task descriptor envelope is flat: CreateTaskResult = Result & Task & +// {resultType: "task"} — no nested "task" object. +// - statusMessage is opaque server data; log lengths, not contents. +// - All polling requests inherit the per-request mcpRequestTimeout budget; +// the overall wait is additionally bounded by taskPollBudget so a hostile +// or stuck server cannot keep a call alive forever. + +// Task lifecycle states (SEP-1686). +const ( + TaskStatusWorking = "working" + TaskStatusInputRequired = "input_required" + TaskStatusCompleted = "completed" + TaskStatusFailed = "failed" + TaskStatusCancelled = "cancelled" +) + +// ResultTypeTask is the resultType discriminator marking a result that is a +// task descriptor instead of the request's logical result. +const ResultTypeTask = "task" + +// taskPollBudget bounds the TOTAL wall-clock time spent polling one task, +// regardless of server-provided pollInterval hints. Generous for legitimate +// long-running tools; still finite against abuse. +const taskPollBudget = 10 * time.Minute + +// Default bounds for the poll sleep interval. The server may hint a +// pollInterval (milliseconds); we clamp it into [200ms, 10s] so a hostile +// server can neither hot-loop us nor stall us past the budget granularity. +const ( + taskPollMin = 200 * time.Millisecond + taskPollMax = 10 * time.Second + // used when the server provides no pollInterval hint + taskPollDefault = 1 * time.Second +) + +// Task is the SEP-1686 task descriptor object. +type Task struct { + TaskID string `json:"taskId"` + Status string `json:"status"` + StatusMessage string `json:"statusMessage,omitempty"` + // PollInterval is the server's hint, in milliseconds, for how long the + // client should wait between tasks/get polls (0 = no hint). + PollInterval int64 `json:"pollInterval,omitempty"` + // TTL is the server-side retention window for the task's result, in + // milliseconds from completion, after which tasks/result may 404. + TTL int64 `json:"ttl,omitempty"` + // CreatedAt / LastUpdatedAt are RFC3339 timestamps. + CreatedAt string `json:"createdAt,omitempty"` + LastUpdatedAt string `json:"lastUpdatedAt,omitempty"` +} + +// CreateTaskResult is the flat result returned by a task-augmented +// tools/call: the standard Result members (none required here) merged with +// the Task fields and the resultType discriminator. +type CreateTaskResult struct { + Task + ResultType string `json:"resultType"` +} + +// ListTasksResult is the tasks/list result. +type ListTasksResult struct { + Tasks []Task `json:"tasks"` + NextCursor string `json:"nextCursor,omitempty"` +} + +// TasksCapability is the server's initialize capability object for tasks +// (SEP-1686). Presence of the key is what gates client features; the inner +// shape (list/cancel/requests) is tolerated but not interpreted. +type TasksCapability struct { + List *struct{} `json:"list,omitempty"` + Cancel *struct{} `json:"cancel,omitempty"` +} + +// TaskRequestOptions is the params.task request option on tools/call +// (SEP-1686): `true` or an object carrying per-task options such as ttl. +// Encoded as json.RawMessage so both shapes pass through untouched. +type TaskRequestOptions = json.RawMessage + +// isTaskEnvelope reports whether a raw result carries the task discriminator. +// The discriminator is required by the flat envelope shape; a bare Task +// without resultType is NOT treated as a task (defense against servers that +// echo taskId on regular results). +func isTaskEnvelope(raw json.RawMessage) bool { + var probe struct { + ResultType string `json:"resultType"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + return false + } + return probe.ResultType == ResultTypeTask +} + +// pollInterval returns the sleep duration derived from a Task's hint. +func (t Task) pollInterval() time.Duration { + d := taskPollDefault + if t.PollInterval > 0 { + d = time.Duration(t.PollInterval) * time.Millisecond + } + if d < taskPollMin { + d = taskPollMin + } + if d > taskPollMax { + d = taskPollMax + } + return d +} + +// CallToolAsTask runs a tool call with the SEP-1686 task request option: +// sends tools/call with params.task set, and when the server answers with a +// task descriptor, polls tasks/get until a terminal state, then fetches the +// final tool result via tasks/result. task is the terminal descriptor (may +// be nil when the server answered synchronously with a plain result). +func (c *Client) CallToolAsTask(ctx context.Context, name string, args map[string]interface{}, opts TaskRequestOptions) (*CallToolResult, *Task, error) { + if c.closed.Load() { + return nil, nil, fmt.Errorf("mcp[%s]: connection closed", c.name) + } + params := CallToolParams{ + Name: name, + Arguments: args, + Task: opts, + } + var raw json.RawMessage + if err := c.callWithMRTR(ctx, "tools/call", ¶ms, &raw); err != nil { + return nil, nil, err + } + if !isTaskEnvelope(raw) { + // Server executed synchronously despite the task option — a plain + // CallToolResult is a legal answer. Treat it as the final result. + var out CallToolResult + if err := json.Unmarshal(raw, &out); err != nil { + return nil, nil, fmt.Errorf("mcp[%s]: tools/call returned malformed result: %w", c.name, err) + } + return &out, nil, nil + } + var created CreateTaskResult + if err := json.Unmarshal(raw, &created); err != nil { + return nil, nil, fmt.Errorf("mcp[%s]: tools/call task envelope malformed: %w", c.name, err) + } + if created.TaskID == "" { + return nil, nil, fmt.Errorf("mcp[%s]: tools/call returned task envelope without taskId", c.name) + } + task, err := c.awaitTaskLoop(ctx, created.Task, created.TaskID, c.GetTask) + if err != nil { + return nil, &task, err + } + switch task.Status { + case TaskStatusCompleted: + result, err := c.TaskResult(ctx, task.TaskID) + if err != nil { + return nil, &task, err + } + return result, &task, nil + case TaskStatusFailed: + return nil, &task, fmt.Errorf("mcp[%s]: task %s failed: %s", c.name, task.TaskID, task.StatusMessage) + case TaskStatusCancelled: + return nil, &task, fmt.Errorf("mcp[%s]: task %s was cancelled", c.name, task.TaskID) + default: + return nil, &task, fmt.Errorf("mcp[%s]: task %s ended in unexpected status %q", c.name, task.TaskID, task.Status) + } +} + +// awaitTaskLoop is the terminal-state poll state machine with an injected +// task fetcher (mrtrLoop-style send func) so tests can drive status +// transitions without a transport. seed supplies the initial status and poll +// hint; taskID identifies the polled task. +func (c *Client) awaitTaskLoop(ctx context.Context, seed Task, taskID string, getTask func(context.Context, string) (Task, error)) (Task, error) { + deadline := time.Now().Add(taskPollBudget) + task := seed + task.TaskID = taskID + for { + switch task.Status { + case TaskStatusCompleted, TaskStatusFailed, TaskStatusCancelled: + return task, nil + case TaskStatusInputRequired: + // SEP-1686 lets tasks pause for client input. Resolving task-level + // input requests requires an interactive flow the caller must + // drive; auto-polling through it would spin forever. + return task, fmt.Errorf("mcp[%s]: task %s is input_required: %s", c.name, task.TaskID, task.StatusMessage) + case TaskStatusWorking, "": + // continue polling + default: + return task, fmt.Errorf("mcp[%s]: task %s has unknown status %q (treated as invalid protocol response)", c.name, task.TaskID, task.Status) + } + if time.Now().After(deadline) { + return task, fmt.Errorf("mcp[%s]: task %s exceeded %s poll budget (task poll guard)", c.name, task.TaskID, taskPollBudget) + } + if err := ctx.Err(); err != nil { + return task, fmt.Errorf("mcp[%s]: task %s polling cancelled: %w", c.name, task.TaskID, err) + } + select { + case <-ctx.Done(): + return task, fmt.Errorf("mcp[%s]: task %s polling cancelled: %w", c.name, task.TaskID, ctx.Err()) + case <-time.After(c.taskPollDelay(task)): + } + next, err := getTask(ctx, task.TaskID) + if err != nil { + return task, fmt.Errorf("mcp[%s]: task %s poll: %w", c.name, task.TaskID, err) + } + debug.Log("mcp-client", "server=%s task=%s status=%s (poll)", c.name, next.TaskID, next.Status) + task = next + } +} + +// taskPollDelay picks the sleep interval, honoring the server hint clamped +// into sane bounds, or the test override when set. +func (c *Client) taskPollDelay(task Task) time.Duration { + if c.taskPollOverride > 0 { + return c.taskPollOverride + } + return task.pollInterval() +} + +// GetTask fetches the current task descriptor via tasks/get. +func (c *Client) GetTask(ctx context.Context, taskID string) (Task, error) { + var result Task + params := map[string]string{"taskId": taskID} + if err := c.sendRequest(ctx, "tasks/get", params, &result); err != nil { + return Task{}, fmt.Errorf("mcp[%s]: tasks/get: %w", c.name, err) + } + if result.TaskID == "" { + result.TaskID = taskID + } + return result, nil +} + +// TaskResult fetches the final result of a completed task via tasks/result. +// The result of a task-augmented tools/call is a CallToolResult. +func (c *Client) TaskResult(ctx context.Context, taskID string) (*CallToolResult, error) { + var result CallToolResult + params := map[string]string{"taskId": taskID} + if err := c.sendRequest(ctx, "tasks/result", params, &result); err != nil { + return nil, fmt.Errorf("mcp[%s]: tasks/result: %w", c.name, err) + } + return &result, nil +} + +// CancelTask requests cancellation via tasks/cancel and returns the updated +// descriptor. Cancellation is best-effort per spec: the returned status may +// still be working briefly. +func (c *Client) CancelTask(ctx context.Context, taskID string) (Task, error) { + var result Task + params := map[string]string{"taskId": taskID} + if err := c.sendRequest(ctx, "tasks/cancel", params, &result); err != nil { + return Task{}, fmt.Errorf("mcp[%s]: tasks/cancel: %w", c.name, err) + } + if result.TaskID == "" { + result.TaskID = taskID + } + return result, nil +} + +// ListTasks returns the server's known tasks via tasks/list, following +// nextCursor pagination under the same maxPaginationPages guard as the +// other List* methods (#562 Bug A semantics). +func (c *Client) ListTasks(ctx context.Context) ([]Task, error) { + if c.closed.Load() { + return nil, fmt.Errorf("mcp[%s]: connection closed", c.name) + } + _, caps := c.negotiatedState() + if caps.Tasks == nil { + debug.Log("mcp-client", "server=%s tasks capability not advertised; returning empty task list", c.name) + return []Task{}, nil + } + var all []Task + cursor := "" + for page := 0; ; page++ { + if page >= maxPaginationPages { + return all, fmt.Errorf("mcp[%s]: tasks/list exceeded %d pagination pages", c.name, maxPaginationPages) + } + params := ListToolsParams{Cursor: cursor} + var result ListTasksResult + if err := c.sendRequest(ctx, "tasks/list", params, &result); err != nil { + return all, fmt.Errorf("mcp[%s]: tasks/list: %w", c.name, err) + } + all = append(all, result.Tasks...) + if result.NextCursor == "" { + return all, nil + } + cursor = result.NextCursor + } +} + +// HasTasks reports whether the server advertised the tasks capability +// (SEP-1686), i.e. whether task-augmented tool calls and task management +// methods are available. +func (c *Client) HasTasks() bool { + _, caps := c.negotiatedState() + return caps.Tasks != nil +} diff --git a/internal/mcp/tasks_test.go b/internal/mcp/tasks_test.go new file mode 100644 index 000000000..46973bbca --- /dev/null +++ b/internal/mcp/tasks_test.go @@ -0,0 +1,143 @@ +package mcp + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" +) + +func TestIsTaskEnvelope(t *testing.T) { + yes := []string{ + `{"resultType":"task","taskId":"t1","status":"working"}`, + `{"taskId":"t1","status":"completed","resultType":"task"}`, + } + for _, s := range yes { + if !isTaskEnvelope(json.RawMessage(s)) { + t.Errorf("expected task envelope: %s", s) + } + } + no := []string{ + `{"content":[{"type":"text","text":"ok"}]}`, + `{"resultType":"complete","content":[]}`, + `{"taskId":"t1","status":"working"}`, // no discriminator + `{}`, + `not json`, + } + for _, s := range no { + if isTaskEnvelope(json.RawMessage(s)) { + t.Errorf("unexpected task envelope: %s", s) + } + } +} + +func TestTaskPollIntervalClamp(t *testing.T) { + cases := []struct { + hint int64 + want time.Duration + }{ + {0, taskPollDefault}, + {50, taskPollMin}, + {2500, 2500 * time.Millisecond}, + {60000, taskPollMax}, + {-100, taskPollDefault}, + } + var task Task + for _, c := range cases { + task.PollInterval = c.hint + if got := task.pollInterval(); got != c.want { + t.Errorf("hint=%d: got %v want %v", c.hint, got, c.want) + } + } +} + +func TestAwaitTaskLoopWorkingThenCompleted(t *testing.T) { + c := NewClient("test", "echo", nil) + c.taskPollOverride = time.Millisecond + + var polls []Task + get := func(ctx context.Context, id string) (Task, error) { + polls = append(polls, Task{TaskID: id, Status: TaskStatusWorking}) + if len(polls) >= 2 { + return Task{TaskID: id, Status: TaskStatusCompleted}, nil + } + return Task{TaskID: id, Status: TaskStatusWorking, PollInterval: 5}, nil + } + task, err := c.awaitTaskLoop(context.Background(), Task{TaskID: "t1", Status: TaskStatusWorking}, "t1", get) + if err != nil { + t.Fatalf("awaitTaskLoop: %v", err) + } + if task.Status != TaskStatusCompleted || task.TaskID != "t1" { + t.Fatalf("unexpected terminal task: %+v", task) + } + if len(polls) != 2 { + t.Fatalf("expected 2 polls, got %d", len(polls)) + } +} + +func TestAwaitTaskLoopSeedTerminalSkipsPolling(t *testing.T) { + c := NewClient("test", "echo", nil) + get := func(ctx context.Context, id string) (Task, error) { + t.Fatal("must not poll a seed-terminal task") + return Task{}, nil + } + task, err := c.awaitTaskLoop(context.Background(), Task{TaskID: "t1", Status: TaskStatusCompleted}, "t1", get) + if err != nil || task.Status != TaskStatusCompleted { + t.Fatalf("seed completed: task=%+v err=%v", task, err) + } +} + +func TestAwaitTaskLoopInputRequired(t *testing.T) { + c := NewClient("test", "echo", nil) + c.taskPollOverride = time.Millisecond + get := func(ctx context.Context, id string) (Task, error) { + return Task{TaskID: id, Status: TaskStatusInputRequired, StatusMessage: "need ask_user"}, nil + } + _, err := c.awaitTaskLoop(context.Background(), Task{Status: TaskStatusWorking}, "t1", get) + if err == nil || !strings.Contains(err.Error(), "input_required") { + t.Fatalf("expected input_required error, got %v", err) + } +} + +func TestAwaitTaskLoopUnknownStatus(t *testing.T) { + c := NewClient("test", "echo", nil) + get := func(ctx context.Context, id string) (Task, error) { + return Task{TaskID: id, Status: "exploded"}, nil + } + _, err := c.awaitTaskLoop(context.Background(), Task{Status: TaskStatusWorking}, "t1", get) + if err == nil || !strings.Contains(err.Error(), "unknown status") { + t.Fatalf("expected unknown-status error, got %v", err) + } +} + +func TestAwaitTaskLoopContextCancelled(t *testing.T) { + c := NewClient("test", "echo", nil) + c.taskPollOverride = time.Millisecond + ctx, cancel := context.WithCancel(context.Background()) + get := func(ctx context.Context, id string) (Task, error) { + cancel() + return Task{TaskID: id, Status: TaskStatusWorking}, nil + } + _, err := c.awaitTaskLoop(ctx, Task{Status: TaskStatusWorking}, "t1", get) + if err == nil || !strings.Contains(err.Error(), "cancelled") { + t.Fatalf("expected cancellation error, got %v", err) + } +} + +func TestClientCapsDeclareTasks(t *testing.T) { + c := NewClient("test", "echo", nil) + caps := c.clientCaps() + if caps.Tasks == nil { + t.Fatal("client capability envelope must advertise tasks (SEP-1686)") + } + // The same declaration must survive the modern _meta envelope round-trip. + meta := c.modernRequestMeta(ProtocolVersion20260728) + raw, err := json.Marshal(meta[MetaKeyClientCapabilities]) + if err != nil { + t.Fatalf("marshal caps: %v", err) + } + if !strings.Contains(string(raw), `"tasks"`) { + t.Fatalf("modern envelope caps missing tasks: %s", raw) + } +} From 77174150bd42312a5a46ea692ab72b7afb8ddd15 Mon Sep 17 00:00:00 2001 From: Junjun Zhang Date: Fri, 18 Sep 2026 11:12:51 +0800 Subject: [PATCH 2/2] test(mcp): guard task-envelope passthrough in the MRTR loop Regression test for the mrtr.go contract CallToolAsTask depends on: a resultType "task" envelope must flow through mrtrLoop to the caller unchanged, without triggering an input_required retry, and with all Task fields (taskId/status/pollInterval) surviving the decode. Co-Authored-By: ggcode Co-Authored-By: ggcode --- internal/mcp/tasks_test.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/internal/mcp/tasks_test.go b/internal/mcp/tasks_test.go index 46973bbca..135c651f8 100644 --- a/internal/mcp/tasks_test.go +++ b/internal/mcp/tasks_test.go @@ -141,3 +141,33 @@ func TestClientCapsDeclareTasks(t *testing.T) { t.Fatalf("modern envelope caps missing tasks: %s", raw) } } + +// TestMRTRTaskEnvelopePassthrough guards the mrtr.go contract that +// CallToolAsTask depends on: a resultType "task" envelope must flow through +// the MRTR loop to the caller instead of being rejected as an unrecognized +// result, and it must not trigger an input_required retry. +func TestMRTRTaskEnvelopePassthrough(t *testing.T) { + params := &CallToolParams{Name: "t"} + var sent []string + send := func() (json.RawMessage, error) { + sent = append(sent, "call") + return json.RawMessage(`{"resultType":"task","taskId":"t1","status":"working","pollInterval":500}`), nil + } + var raw json.RawMessage + if err := NewClient("test", "echo", nil).mrtrLoop(context.Background(), "tools/call", params, send, &raw); err != nil { + t.Fatalf("mrtrLoop: %v", err) + } + if len(sent) != 1 { + t.Fatalf("task envelope must not trigger MRTR retry, got %d sends", len(sent)) + } + if !isTaskEnvelope(raw) { + t.Fatalf("task envelope must pass through untouched: %s", raw) + } + var created CreateTaskResult + if err := json.Unmarshal(raw, &created); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if created.TaskID != "t1" || created.Status != TaskStatusWorking || created.PollInterval != 500 { + t.Fatalf("task fields lost in passthrough: %+v", created.Task) + } +}