Skip to content

Commit 323d62a

Browse files
feat(tools): add continue_task and cancel_task, model selection guide, Codex support
- opencode_continue_task: send follow-up prompts to an existing task session - opencode_cancel_task: abort a running task via session abort - delegate_task prompt: 4-tier model selection guide (frontier/high/mid/fast) - start_task: document agent/model params and discovery via list_agents - package.json: add description and keywords for npm discoverability - README: Codex CLI config, new tools, broaden supported MCP hosts - AGENTS.md: fix stale colocated-tests convention (tests live under tests/)
1 parent 73024b3 commit 323d62a

12 files changed

Lines changed: 473 additions & 13 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Package manager is **pnpm** (only `pnpm-lock.yaml` exists). Scripts call `npx`/`
2323

2424
## Testing
2525

26-
Every module has a colocated `<name>.test.ts` Vitest suite next to its source. `src/test-utils/fake-mcp-server.ts` provides a fake `McpServer` for testing tool/prompt registration without a real transport. Tests run in a `node` environment; `src/test-utils/` is excluded from coverage. When adding or changing a module, keep its test file at 100% coverage or `pnpm test:coverage` fails.
26+
Every module has a `<name>.test.ts` Vitest suite under the parallel `tests/` tree mirroring `src/` (e.g. `src/modules/tools/start_task.ts` → `tests/modules/tools/start_task.test.ts`; see `vitest.config.ts` `include`). `src/test-utils/fake-mcp-server.ts` provides a fake `McpServer` for testing tool/prompt registration without a real transport. Tests run in a `node` environment; `src/test-utils/` is excluded from coverage. When adding or changing a module, keep its test file at 100% coverage or `pnpm test:coverage` fails.
2727

2828
## Architecture
2929

@@ -45,7 +45,7 @@ Every module has a colocated `<name>.test.ts` Vitest suite next to its source. `
4545
2. Register it in `src/modules/tools/index.ts` (import + call inside `registerTools`).
4646
3. The tool name passed to `server.registerTool` must be `opencode_<name>`; define `inputSchema` with Zod.
4747
4. Reuse `shared/mcp-result.ts` (`jsonResult`/`jsonError`) for results and `shared/opencode-client.ts` (`clientForServer`/`clientForTask`) for SDK access instead of constructing clients ad hoc — other tools rely on the registries staying the source of truth for server/task identity.
48-
5. Add a colocated `<name>.test.ts` (use `src/test-utils/fake-mcp-server.ts` to capture the registration) — coverage thresholds are 100%.
48+
5. Add `tests/modules/tools/<name>.test.ts` (use `src/test-utils/fake-mcp-server.ts` to capture the registration) — coverage thresholds are 100%.
4949

5050
## Gotchas
5151

‎README.md‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# opencode-mcp
22

3-
An MCP (Model Context Protocol) server that lets Claude Code drive an [OpenCode](https://opencode.ai) instance and delegate work to its subagents — so orchestrator models like Opus or Fable can hand off tasks to the other models OpenCode exposes.
3+
An MCP (Model Context Protocol) server that lets any MCP host — Claude Code, Codex CLI, Cursor, etc. — drive an [OpenCode](https://opencode.ai) instance and delegate work to its subagents — so orchestrator models like Opus or Fable can hand off tasks to the other models OpenCode exposes.
44

55
```
66
Claude Code
@@ -26,15 +26,17 @@ Task delegation is **asynchronous**: starting a task returns immediately with a
2626
| `opencode_start_server` | Start (or attach to) an OpenCode server instance |
2727
| `opencode_stop_server` | Stop a running OpenCode server instance |
2828
| `opencode_list_agents` | List agents/models available on a server instance |
29-
| `opencode_start_task` | Delegate a task to an agent by starting a new session and prompt |
29+
| `opencode_start_task` | Delegate a task to an agent by starting a new session and prompt (optional `agent` / `model` override) |
30+
| `opencode_continue_task` | Send a follow-up prompt to an existing task's session for iterative back-and-forth with the subagent |
31+
| `opencode_cancel_task` | Abort a running delegated task by cancelling its session |
3032
| `opencode_get_task_status` | Poll the status of a delegated task (`pending` / `running` / `completed` / `failed`) |
3133
| `opencode_get_task_result` | Fetch the final result of a completed task |
3234
| `opencode_wait_for_task` | Long-poll one or more delegated tasks until they finish (`mode: "all"` or `"any"`) or the timeout elapses |
3335

3436

3537
| Prompt | Description |
3638
| --------------- | ---------------------------------------------------------------------------------------------------- |
37-
| `delegate_task` | Guides the host through delegating one or more tasks to OpenCode agents (start/wait/result workflow) |
39+
| `delegate_task` | Guides the host through delegating one or more tasks to OpenCode agents (start/wait/result workflow), including a model selection guide that maps each OpenCode model tier to the task difficulty it should handle |
3840

3941
## Installation
4042

@@ -66,6 +68,14 @@ Or manually
6668
}
6769
```
6870

71+
For **Codex CLI**, add the server to `~/.codex/config.toml`:
72+
73+
```toml
74+
[mcp_servers.opencode]
75+
command = "npx"
76+
args = ["-y", "mcp-server-opencode"]
77+
```
78+
6979
Or install it globally and use the binary directly:
7080

7181
```bash

‎package.json‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
{
22
"name": "mcp-server-opencode",
33
"version": "1.0.0",
4-
"description": "",
4+
"description": "MCP server that lets any MCP host (Claude Code, Codex CLI, Cursor, etc.) drive OpenCode and delegate tasks asynchronously to its agents",
55
"main": "./build/src/index.js",
66
"types": "./build/src/index.d.ts",
7-
"keywords": [],
7+
"keywords": [
8+
"mcp",
9+
"mcp-server",
10+
"model-context-protocol",
11+
"opencode",
12+
"agent",
13+
"subagent",
14+
"delegation",
15+
"async",
16+
"orchestration",
17+
"claude-code",
18+
"codex",
19+
"ai-agents"
20+
],
821
"author": "alejandro-technology",
922
"license": "MIT",
1023
"repository": {

‎src/modules/prompts/delegate_task.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,28 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22

33
const DELEGATE_TASK_INSTRUCTIONS = `You have access to the opencode-mcp server, which lets you delegate work to OpenCode agents. Follow this workflow whenever you delegate tasks:
44
5-
Recommended models: run delegated tasks with Fable 5, Opus 4.8, or GPT 5.6 Sol. When overriding an agent's model via opencode_start_task, prefer one of these.
5+
Model selection guide — pick the model per task via the optional "model" input of opencode_start_task ('providerID/modelID'). Match the model tier to the task's difficulty; do not burn a scarce frontier model on work a cheap model handles well. Usage quotas (requests per 5h) are noted as a scarcity signal — lower quota means reserve it for harder work.
6+
7+
Frontier tier (scarce — architecture, hard debugging, critical decisions):
8+
- Grok 4.5 (~80 req/5h): top-end reasoning and coding; reserve for the hardest problems, cross-cutting refactors, and tasks where a wrong answer is expensive.
9+
- Kimi K3 (~140 req/5h): frontier reasoning with very strong agentic stability and tool calling over long sessions; best for long-horizon multi-step tasks that must not derail.
10+
11+
High tier (strong daily drivers — feature implementation, non-trivial debugging, review):
12+
- GLM-5.2 (~880 req/5h): open-weight leader on coding and agentic-coding benchmarks (SWE-bench Pro ~62%); best default for substantial implementation and refactoring work.
13+
- Qwen3.7 Max (~950 req/5h): near-tie with GLM-5.2 on real-world software engineering; strong multilingual and long-context work.
14+
- Kimi K2.7 Code (~1,150 req/5h): coding-specialized; ~30% fewer thinking tokens, excellent precise tool invocation (MCP-heavy pipelines) and 4,000+ tool-call sequences; best for long agentic coding runs.
15+
16+
Mid tier (high volume — standard features, tests, medium complexity):
17+
- MiniMax M3 (~3,200 req/5h): frontier-adjacent coding plus 1M context and native multimodality; best for large-codebase context ingestion or tasks involving images/video.
18+
- MiMo-V2.5-Pro (~3,250 req/5h): strong general agentic capability and complex software engineering (high SWE-bench Pro rankings); good GLM alternative at higher volume.
19+
- DeepSeek V4 Pro (~3,450 req/5h): solid all-round flagship; good balance of capability and quota for everyday implementation tasks.
20+
- Qwen3.7 Plus (~4,300 req/5h): capable mid-range generalist; good for standard features and tests at volume.
21+
22+
Fast tier (near-unlimited — mechanical work, boilerplate, formatting, simple fixes, docs, exploration):
23+
- MiMo-V2.5 (~30,100 req/5h): lightweight reasoning model; better than Flash on agentic tasks, at some latency/token cost.
24+
- DeepSeek V4 Flash (~31,650 req/5h): cheapest and fastest, non-reasoning; best for mechanical edits, boilerplate, renames, doc updates, and quick lookups where latency matters.
25+
26+
Rule of thumb: exploration/mechanical → Fast tier; standard implementation → Mid tier; complex features and long agentic runs → High tier; architecture or hardest debugging → Frontier tier.
627
728
Workflow:
829
1. Ensure an OpenCode server is running. If you don't already have a server_id from a previous opencode_start_server call, call opencode_start_server first.

‎src/modules/tools/cancel_task.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { z } from "zod";
3+
import { jsonError, jsonResult } from "../shared/mcp-result.js";
4+
import { clientForTask } from "../shared/opencode-client.js";
5+
6+
export function registerOpencodeCancelTask(server: McpServer) {
7+
server.registerTool(
8+
"opencode_cancel_task",
9+
{
10+
description: "Cancel a delegated task by aborting its OpenCode session",
11+
inputSchema: {
12+
task_id: z.string().describe("Id of the task to cancel"),
13+
},
14+
},
15+
async ({ task_id }) => {
16+
const resolved = clientForTask(task_id);
17+
if (!resolved) {
18+
return jsonError({ task_id, status: "task_not_found" });
19+
}
20+
const { client, sessionId } = resolved;
21+
22+
try {
23+
await client.session.abort({ path: { id: sessionId } });
24+
25+
return jsonResult({
26+
task_id,
27+
session_id: sessionId,
28+
status: "cancelled",
29+
});
30+
} catch (error) {
31+
return jsonError({
32+
task_id,
33+
status: "error",
34+
message: error instanceof Error ? error.message : String(error),
35+
});
36+
}
37+
},
38+
);
39+
}

‎src/modules/tools/continue_task.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { z } from "zod";
3+
import { jsonError, jsonResult } from "../shared/mcp-result.js";
4+
import { clientForTask } from "../shared/opencode-client.js";
5+
6+
/** Parse a "providerID/modelID" string into the shape the SDK expects. */
7+
function parseModel(model: string): { providerID: string; modelID: string } | undefined {
8+
const slash = model.indexOf("/");
9+
if (slash <= 0 || slash === model.length - 1) return undefined;
10+
return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };
11+
}
12+
13+
export function registerOpencodeContinueTask(server: McpServer) {
14+
server.registerTool(
15+
"opencode_continue_task",
16+
{
17+
description: "Send a follow-up prompt to an existing delegated task's session",
18+
inputSchema: {
19+
task_id: z
20+
.string()
21+
.describe("Id of the task whose session should receive the follow-up prompt"),
22+
prompt: z.string().describe("Follow-up prompt/instructions for the agent"),
23+
agent: z
24+
.string()
25+
.optional()
26+
.describe(
27+
"Agent name to delegate to (e.g. 'build', 'plan'). Discover available agents with opencode_list_agents. Omit to use the session's current agent",
28+
),
29+
model: z
30+
.string()
31+
.optional()
32+
.describe(
33+
"Model override as 'providerID/modelID' (e.g. 'opencode/glm-5.2'). Overrides the agent's pre-assigned model for this follow-up only. Discover available models with opencode_list_agents",
34+
),
35+
},
36+
},
37+
async ({ task_id, prompt, agent, model }) => {
38+
const resolved = clientForTask(task_id);
39+
if (!resolved) {
40+
return jsonError({ task_id, status: "task_not_found" });
41+
}
42+
const { client, sessionId } = resolved;
43+
44+
let parsedModel: { providerID: string; modelID: string } | undefined;
45+
if (model !== undefined) {
46+
parsedModel = parseModel(model);
47+
if (!parsedModel) {
48+
return jsonError({
49+
task_id,
50+
status: "invalid_model",
51+
message: "model must be in 'providerID/modelID' format",
52+
});
53+
}
54+
}
55+
56+
try {
57+
// Fire-and-forget: promptAsync returns as soon as the prompt is accepted,
58+
// the agent keeps working in the background. Poll with get_task_status.
59+
await client.session.promptAsync({
60+
path: { id: sessionId },
61+
body: {
62+
parts: [{ type: "text", text: prompt }],
63+
...(agent ? { agent } : {}),
64+
...(parsedModel ? { model: parsedModel } : {}),
65+
},
66+
});
67+
68+
return jsonResult({
69+
task_id,
70+
session_id: sessionId,
71+
status: "pending",
72+
});
73+
} catch (error) {
74+
return jsonError({
75+
task_id,
76+
status: "error",
77+
message: error instanceof Error ? error.message : String(error),
78+
});
79+
}
80+
},
81+
);
82+
}

‎src/modules/tools/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
// Tools
3+
import { registerOpencodeCancelTask } from "./cancel_task.js";
4+
import { registerOpencodeContinueTask } from "./continue_task.js";
35
import { registerOpencodeGetTaskResult } from "./get_task_result.js";
46
import { registerOpencodeGetTaskStatus } from "./get_task_status.js";
57
import { registerOpencodeListAgents } from "./list_agents.js";
@@ -16,4 +18,6 @@ export function registerTools(server: McpServer) {
1618
registerOpencodeGetTaskStatus(server);
1719
registerOpencodeGetTaskResult(server);
1820
registerOpencodeWaitForTask(server);
21+
registerOpencodeContinueTask(server);
22+
registerOpencodeCancelTask(server);
1923
}

‎src/modules/tools/start_task.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,18 @@ export function registerOpencodeStartTask(server: McpServer) {
2121
inputSchema: {
2222
server_id: z.string().describe("Id of the server instance to run the task on"),
2323
prompt: z.string().describe("Prompt/instructions for the agent"),
24-
agent: z.string().optional().describe("Agent name to delegate to (e.g. 'build')"),
24+
agent: z
25+
.string()
26+
.optional()
27+
.describe(
28+
"Agent name to delegate to (e.g. 'build', 'plan'). Discover available agents with opencode_list_agents. Omit to use the server's default agent",
29+
),
2530
model: z
2631
.string()
2732
.optional()
28-
.describe("Model as 'providerID/modelID' (e.g. 'anthropic/claude-sonnet-4')"),
33+
.describe(
34+
"Model override as 'providerID/modelID' (e.g. 'opencode/glm-5.2'). Overrides the agent's pre-assigned model for this task only. Discover available models with opencode_list_agents; see the delegate_task prompt for guidance on matching model tier to task difficulty",
35+
),
2936
},
3037
},
3138
async ({ server_id, prompt, agent, model }) => {

‎tests/modules/prompts/delegate_task.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ describe("registerDelegateTaskPrompt", () => {
1616
expect(config).not.toHaveProperty("argsSchema");
1717
});
1818

19-
it("returns static workflow instructions with the recommended models", () => {
19+
it("returns static workflow instructions with the model selection guide", () => {
2020
const { server, getPromptHandler } = createFakeMcpServer();
2121
registerDelegateTaskPrompt(server);
2222
const handler = getPromptHandler<() => { messages: unknown[] }>();
@@ -25,7 +25,9 @@ describe("registerDelegateTaskPrompt", () => {
2525
const text = (result.messages[0] as { content: { text: string } }).content.text;
2626

2727
expect(result.messages).toHaveLength(1);
28-
expect(text).toContain("Fable 5, Opus 4.8, or GPT 5.6 Sol");
28+
expect(text).toContain("Model selection guide");
29+
expect(text).toContain("Frontier tier");
30+
expect(text).toContain("Fast tier");
2931
expect(text).toContain("opencode_start_server");
3032
expect(text).toContain("opencode_list_agents");
3133
expect(text).toContain("opencode_start_task");
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { createFakeMcpServer } from "../../../src/test-utils/fake-mcp-server.js";
3+
4+
const clientForTaskMock = vi.fn();
5+
6+
vi.mock("../../../src/modules/shared/opencode-client.js", () => ({
7+
clientForTask: (...args: unknown[]) => clientForTaskMock(...args),
8+
}));
9+
10+
const { registerOpencodeCancelTask } = await import("../../../src/modules/tools/cancel_task.js");
11+
12+
describe("opencode_cancel_task", () => {
13+
beforeEach(() => {
14+
clientForTaskMock.mockReset();
15+
});
16+
17+
it("returns task_not_found when the task cannot be resolved", async () => {
18+
clientForTaskMock.mockReturnValue(undefined);
19+
const fake = createFakeMcpServer();
20+
registerOpencodeCancelTask(fake.server);
21+
const handler = fake.getHandler();
22+
23+
const result = await handler({ task_id: "missing" });
24+
25+
expect(result).toEqual({
26+
isError: true,
27+
content: [
28+
{ type: "text", text: JSON.stringify({ task_id: "missing", status: "task_not_found" }) },
29+
],
30+
});
31+
});
32+
33+
it("aborts the session and returns cancelled status", async () => {
34+
const abort = vi.fn().mockResolvedValue({});
35+
clientForTaskMock.mockReturnValue({
36+
client: { session: { abort } },
37+
sessionId: "session-1",
38+
});
39+
const fake = createFakeMcpServer();
40+
registerOpencodeCancelTask(fake.server);
41+
const handler = fake.getHandler();
42+
43+
const result = await handler({ task_id: "task-1" });
44+
45+
expect(abort).toHaveBeenCalledWith({ path: { id: "session-1" } });
46+
expect(result).toEqual({
47+
content: [
48+
{
49+
type: "text",
50+
text: JSON.stringify({
51+
task_id: "task-1",
52+
session_id: "session-1",
53+
status: "cancelled",
54+
}),
55+
},
56+
],
57+
});
58+
});
59+
60+
it("returns an error result when the SDK throws an Error", async () => {
61+
clientForTaskMock.mockReturnValue({
62+
client: { session: { abort: vi.fn().mockRejectedValue(new Error("db down")) } },
63+
sessionId: "session-1",
64+
});
65+
const fake = createFakeMcpServer();
66+
registerOpencodeCancelTask(fake.server);
67+
const handler = fake.getHandler();
68+
69+
const result = await handler({ task_id: "task-1" });
70+
71+
expect(result).toEqual({
72+
isError: true,
73+
content: [
74+
{
75+
type: "text",
76+
text: JSON.stringify({ task_id: "task-1", status: "error", message: "db down" }),
77+
},
78+
],
79+
});
80+
});
81+
82+
it("returns an error result when the SDK throws a non-Error value", async () => {
83+
clientForTaskMock.mockReturnValue({
84+
client: { session: { abort: vi.fn().mockRejectedValue("weird") } },
85+
sessionId: "session-1",
86+
});
87+
const fake = createFakeMcpServer();
88+
registerOpencodeCancelTask(fake.server);
89+
const handler = fake.getHandler();
90+
91+
const result = await handler({ task_id: "task-1" });
92+
93+
expect(result).toEqual({
94+
isError: true,
95+
content: [
96+
{
97+
type: "text",
98+
text: JSON.stringify({ task_id: "task-1", status: "error", message: "weird" }),
99+
},
100+
],
101+
});
102+
});
103+
});

0 commit comments

Comments
 (0)