diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa5e3c0..bf911e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,4 +24,6 @@ jobs: cache: npm - run: npm ci - run: npm test + - run: npm run docs:check + - run: npm pack --dry-run - run: git diff --check diff --git a/.gitignore b/.gitignore index 941a2a6..82effd7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ projects/ # Local review/scratch artifacts (e.g. dumped PR comments) pr_comments.txt *_comments.txt + +# Local test coverage reports +coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 067113a..8020c61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [3.0.0] - 2026-09-16 + +### Breaking changes + +- Require Node.js 22 or newer. Upgrade the runtime used by your MCP client before installing 3.0.0. +- Move the MCP implementation to the split SDK v2 packages. Keep legacy stdio clients supported through protocol negotiation; native Tasks and interactive input depend on client capabilities. + +### Added + +- Durable local job handles and result storage, with get/list/cancel/input tools and default 24-hour retention. Override the storage directory with `OPENCODE_TASK_STORE`. +- Native MCP Tasks support for `opencode_run` through the stdio adapter, with explicit polling and ordinary fire/check/wait fallback. +- Pending question list/reply/reject tools and explicit permission/question responses for jobs. Supporting clients can present interactive input; permissions are never silently approved. +- Optional `full` and `essential` tool profiles selected with `OPENCODE_TOOL_PROFILE`. +- Structured MCP outputs alongside readable text, plus optional OpenCode JSON-schema response formats for prompts. +- Project/session resource templates with encoded absolute server paths; static resources retain their default-project scope. + +### Fixed + +- Recognize completed JSON-schema tool responses, show the validated JSON in readable output, and recover correlated async results from OpenCode 1.18.31 response-format history serialization failures. Document the required `StructuredOutput` permission. +- Correlate async observation with submitted work and report accepted, running, input-required, completed, failed, cancelled, and unknown states accurately. Observation timeouts preserve progress and do not imply cancellation. +- Preserve request deadlines and cancellation, avoid unsafe automatic mutation retries, and scope event streams to the requested project. +- Redact sensitive configuration/provider fields and preserve valid JSON when responses are truncated. +- Make tool annotations and generated documentation match the registered public surface. +- Replace unsafe smoke-test session fallback with a disposable project and owned-session cleanup. Unexpected backend errors now fail verification; inference requires an explicit provider/model opt-in and skipped tools are reported honestly. +- Refresh client configuration examples, remote path guidance, resource capabilities, development checks, and release verification instructions. + +### Migration notes + +- Keep OpenCode running externally if work must continue after MCP disconnect. Persisted job records allow resumed observation; they do not checkpoint inference or restart an auto-started child that was closed with MCP. +- Clients should inspect structured state and identifiers rather than parse status text. A timeout means observation ended, not that the job failed. Check existing work before retrying an ambiguous submission. +- The essential profile reduces advertised tools but is not a sandbox. OpenCode's project permission policy still controls delegated coding operations. ## [2.0.1] - 2026-09-15 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 41e7bc6..47099c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,99 +1,66 @@ # Contributing to opencode-mcp -Thanks for your interest in contributing! This project is open to everyone. +## Setup -## Getting Started +Use Node.js 22 or newer and Git. Fork and clone the repository, then: -1. **Fork** the repository -2. **Clone** your fork: - ```bash - git clone https://github.com//opencode-mcp.git - cd opencode-mcp - ``` -3. **Install dependencies**: - ```bash - npm install - ``` -4. **Build**: - ```bash - npm run build - ``` -5. **Test locally**: start an OpenCode server (`opencode serve`) and run: - ```bash - npm start - ``` +```bash +npm ci +npm test +``` + +`npm test` builds the CLI and runs the regression suite. Automated tests use local fixtures; they do not require an OpenCode installation, provider credentials, or paid model calls. + +For manual use, build with `npm run build`, start an OpenCode server explicitly, and configure your MCP client to run `node` with the absolute path to `dist/index.js`. `npm run dev` rebuilds on edits; restart the MCP connection to load the updated process. ## Development Workflow -1. Create a branch from `main`: - ```bash - git checkout -b feat/my-feature - ``` -2. Make your changes in `src/` -3. Build and verify: +1. Create a focused branch from `main`. +2. Implement the change and add regression coverage for its meaningful failure modes. +3. Run `npm run docs:generate` when changing registration schemas, and update workflow/migration documentation when behavior changes. +4. Run: ```bash npm test + npm run docs:check + npm run test:coverage npm audit git diff --check ``` -4. Test manually against a running OpenCode server -5. Commit with a descriptive message: - ```bash - git commit -m "feat: add support for X" - ``` -6. Push and open a pull request against `main` - -## Commit Messages - -Use [Conventional Commits](https://www.conventionalcommits.org/): +5. Review your diff and open a pull request describing the problem, resulting behavior, and validation. -- `feat:`: new feature -- `fix:`: bug fix -- `chore:`: maintenance (deps, config, CI) -- `docs:`: documentation changes -- `refactor:`: code restructuring without behavior change - -## What to Contribute - -- New tools wrapping OpenCode API endpoints -- Bug fixes for existing tools -- Better error handling or response formatting -- Documentation improvements -- MCP client configuration examples -- Tests +Use descriptive commits such as `fix: preserve async job state after timeout` or `feat: add project resource templates`. Keep credentials, live session data, generated test projects, and personal MCP configuration out of commits. ## Project Structure -``` -src/ - index.ts Entry point - client.ts HTTP client (retry, SSE, errors) - helpers.ts Response formatting utilities - resources.ts MCP Resources - prompts.ts MCP Prompts - tools/ Tool implementations (one file per domain) -``` +- `src/index.ts`: startup and registration. +- `src/mcp-server.ts`: MCP registration adapter and shared output contracts. +- `src/client.ts`: SDK-backed HTTP/SSE transport. +- `src/server-manager.ts`: explicit server connection and opt-in child lifecycle. +- `src/async.ts`: async observation and correlation. +- `src/helpers.ts`: formatting, redaction, shared inputs. +- `src/tools/`: low-level API tools and combined workflows. +- `src/resources.ts`, `src/prompts.ts`: resources and prompt templates. +- `tests/`: unit, HTTP, stdio, and smoke-runner regression tests. -## Adding a New Tool +## Adding or Changing a Tool -1. Find the appropriate file in `src/tools/` (or create a new one) -2. Register the tool with `server.tool(name, description, schema, handler)` -3. Use `toolResult()`, `toolError()`, and `toolJson()` from `helpers.ts` -4. Add the tool to the README and `docs/tools.md` +Use the registration adapter in the appropriate tool group. Include an input schema, truthful behavior annotations, and machine-readable outputs alongside readable text. Preserve intentional global/project scope and propagate cancellation, deadlines, and request metadata where applicable. -## Code Style +Use shared result and error helpers. Do not swallow transport failures into successful empty results, automatically retry ambiguous mutations, or infer async completion from an absent busy status alone. Inputs for permissions/questions must reflect an explicit response, not an inferred approval. -- TypeScript with ES modules (`"type": "module"`) -- Use the helper functions from `helpers.ts` instead of raw `JSON.stringify` -- Handle errors gracefully: return `toolError()` instead of throwing +The generated tools reference must stay synchronized with registered schemas and profiles. Add contract coverage when changing the public API; use integration coverage for transport or protocol behavior rather than testing only a mocked handler. -## Questions? +## Live Tests and Releases -Open an [issue](https://github.com/AlaeddineMessadi/opencode-mcp/issues) for questions, bugs, or feature requests. +The default live runner uses a disposable local Git project and only sessions it created: +```bash +npm run build +node scripts/mcp-smoke-test.mjs +``` -## Regression Tests +Inference is disabled unless explicitly enabled with a provider/model pair. Read [live verification and releasing](docs/releasing.md) before running model checks or preparing a publication. PASS applies only to checked capabilities; intentional SKIP entries are not coverage. -`npm test` builds the CLI and runs unit, HTTP transport and stdio process tests. The automated tests use local fixtures and do not require an OpenCode install or paid model calls. Node.js 22 or newer is recommended for development because Vitest 4 requires it; the published CLI supports Node.js 18 and newer. +For bug reports, include the OpenCode version, package version, MCP client, Node version, client/server operating systems, and whether the OpenCode server is local, remote, or in WSL. Describe whether the issue concerns an existing server or an auto-started child. Sanitize credentials and private source/session content. -Report the OpenCode version, MCP version, client OS, server OS and whether the server is local, remote, or running in WSL. Keep one issue's fix in a focused commit and reference its issue in the PR. Changes to startup defaults or tool parameters should include migration notes. +Changes to runtime requirements, startup defaults, tool inputs, result schemas, or job recovery should include migration notes in the changelog. Merging a PR does not publish npm automatically. diff --git a/README.md b/README.md index e424664..f974ba9 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,32 @@ # opencode-mcp [![npm version](https://img.shields.io/npm/v/opencode-mcp)](https://www.npmjs.com/package/opencode-mcp) -[![license](https://img.shields.io/github/license/AlaeddineMessadi/opencode-mcp)](https://github.com/AlaeddineMessadi/opencode-mcp/blob/main/LICENSE) +[![license](https://img.shields.io/github/license/AlaeddineMessadi/opencode-mcp)](LICENSE) [![node](https://img.shields.io/node/v/opencode-mcp)](https://nodejs.org/) -[![npm downloads](https://img.shields.io/npm/dm/opencode-mcp)](https://www.npmjs.com/package/opencode-mcp) -**Give any MCP client the power of [OpenCode](https://opencode.ai/).** +**Delegate coding work to OpenCode from your MCP client.** -opencode-mcp is an MCP server that bridges your AI tools (Claude, Cursor, Windsurf, VS Code, etc.) to OpenCode's headless API. It lets your AI delegate real coding work: building features, debugging, refactoring, running tests: to OpenCode sessions that autonomously read, write, and execute code in your project. +opencode-mcp connects Claude, Cursor, VS Code, and other MCP clients to OpenCode's headless API. Ask questions, implement features, monitor background work, respond to questions and permissions, and review changes across projects. -**80 tools** | **10 resources** | **6 prompts** | **Multi-project** | **Optional auto-start** - -## Why Use This? - -- **Delegate coding tasks**: Tell Claude "build me a REST API" and it delegates to OpenCode, which creates files, installs packages, writes tests, and reports back. -- **Parallel work**: Fire off multiple tasks to OpenCode while your primary AI keeps working on something else. -- **Any MCP client**: Works with Claude Desktop, Claude Code, Cursor, Windsurf, VS Code Copilot, Cline, Continue, Zed, Amazon Q, and any other MCP-compatible tool. -- **Explicit server control**: Connect to an existing OpenCode server or opt in to launching a local server with `OPENCODE_AUTO_SERVE=true`. +> Version 3.0.0 requires **Node.js 22 or newer**. Upgrading from 2.x? See the [migration notes](CHANGELOG.md#300---2026-09-16). ## Quick Start -> **Prerequisite:** [OpenCode](https://opencode.ai/) must be installed. -> `curl -fsSL https://opencode.ai/install | bash` or `npm i -g opencode-ai` or `brew install sst/tap/opencode` - -Start a server first: +Install [OpenCode](https://opencode.ai/docs/) and start its server from your project: ```bash opencode serve --hostname 127.0.0.1 --port 4096 ``` -If you already use the TUI, start it with `opencode --port 4096` and connect MCP to that same server. For a different port, set `OPENCODE_BASE_URL`. +If you use the TUI, start it with `opencode --port 4096` and share that server. Set `OPENCODE_BASE_URL` for another endpoint. -**Claude Code:** +For Claude Code: ```bash claude mcp add opencode -- npx -y opencode-mcp ``` -**Claude Desktop / Cursor / Windsurf / Cline / Continue** (add to your MCP config): +For clients using an `mcpServers` configuration: ```json { @@ -50,179 +39,86 @@ claude mcp add opencode -- npx -y opencode-mcp } ``` -That's it. Restart your client and OpenCode's tools will be available. - -> See [Configuration](docs/configuration.md) for all client configs (VS Code Copilot, Zed, Amazon Q, etc.) and environment variables. - -## How It Works - -``` -MCP Client <--stdio--> opencode-mcp <--HTTP--> OpenCode Server -(Claude, Cursor, etc.) (this package) (existing TUI/serve server, - or SDK-managed child process) -``` - -Your MCP client calls tools over stdio. This server translates them into HTTP requests to the OpenCode headless API. By default it connects to the server at `OPENCODE_BASE_URL`. With `OPENCODE_AUTO_SERVE=true`, the official SDK can launch `opencode serve` as a child process on a local loopback address. The `directory` parameter on every tool routes that request to a specific project using the server-side directory scope, so a single MCP instance can fan out across many project roots. +Restart the client and call `opencode_setup`. Choose a provider from its configured providers, then use `opencode_provider_models` to select a model. Set `OPENCODE_DEFAULT_PROVIDER` and `OPENCODE_DEFAULT_MODEL` together or pass the selected IDs in each prompt call. -## Key Tools +[Client-specific configuration](docs/configuration.md) includes VS Code, Windsurf, Continue, Zed, and Amazon Q. To test unreleased changes, [build from source](CONTRIBUTING.md) and configure your client to run `node` with the absolute path to `dist/index.js`. -The 80 tools are organized into tiers. Start with the workflow tools: they handle the common patterns in a single call. +## Choose a Workflow -### Workflow Tools (13): Start Here - -| Tool | What it does | +| Need | Tools | |---|---| -| `opencode_setup` | Check server health, providers, and project status. Use first. | -| `opencode_ask` | Create session + send prompt + get answer. One call. | -| `opencode_reply` | Follow-up message in an existing session | -| `opencode_run` | Send a task and wait for completion (session + async send + polling) | -| `opencode_fire` | Fire-and-forget: dispatch a task, return immediately | -| `opencode_check` | Compact progress report for a running session (status, todos, files changed) | -| `opencode_conversation` | Get formatted conversation history | -| `opencode_sessions_overview` | Quick overview of all sessions | -| `opencode_context` | Project + VCS + config + agents in one call | -| `opencode_review_changes` | Formatted diff summary for a session | -| `opencode_wait` | Poll an async session until it finishes | -| `opencode_provider_test` | Quick-test whether a provider is working | -| `opencode_status` | Health + providers + sessions + VCS dashboard | - -### Recommended Patterns - -**Quick question:** -``` -opencode_ask({ prompt: "Explain the auth flow in this project" }) -``` +| Setup and orientation | `opencode_setup`, `opencode_context`, `opencode_provider_models` | +| Quick question or follow-up | `opencode_ask`, `opencode_reply` | +| Start work and wait | `opencode_run` | +| Work in the background | `opencode_fire`, then `opencode_check` or `opencode_wait` | +| Recover or control a recorded job | `opencode_job_list`, `opencode_job_get`, `opencode_job_cancel` | +| Resolve required input | `opencode_job_input`, permission and question tools | +| Review the result | `opencode_review_changes`, `opencode_conversation` | -**Build something and wait:** +```javascript +opencode_fire({ + directory: "/absolute/path/to/project", + prompt: "Add input validation to POST /api/users and run the relevant tests", + providerID: "", + modelID: "" +}) +// Save the returned job and session IDs; use them to monitor or resume observation. ``` -opencode_run({ prompt: "Add input validation to POST /api/users", maxDurationSeconds: 300 }) -``` - -**Parallel background tasks:** -``` -opencode_fire({ prompt: "Refactor the auth module to use JWT" }) -→ returns sessionId immediately -opencode_check({ sessionId: "..." }) -→ check progress anytime -``` - -### All Tool Categories - -| Category | Count | Description | -|---|---|---| -| [Workflow](docs/tools.md#workflow-tools) | 13 | High-level composite operations | -| [Session](docs/tools.md#session-tools) | 20 | Create, list, fork, share, abort, revert, permissions | -| [Message](docs/tools.md#message-tools) | 6 | Send prompts, execute commands, run shell | -| [File & Search](docs/tools.md#file--search-tools) | 6 | Search text/regex, find files/symbols, read files | -| [System](docs/tools.md#system--monitoring-tools) | 13 | Health, VCS, LSP, MCP servers, agents, logging | -| [TUI Control](docs/tools.md#tui-control-tools) | 9 | Remote-control the OpenCode terminal UI | -| [Provider & Auth](docs/tools.md#provider--auth-tools) | 6 | List providers/models, set API keys, OAuth | -| [Config](docs/tools.md#config-tools) | 3 | Get/update configuration | -| [Project](docs/tools.md#project-tools) | 3 | List, inspect, and initialize projects | -| [Events](docs/tools.md#event-tools) | 1 | Poll real-time SSE events | -### Resources (10) +Async results distinguish `accepted`, `running`, `input_required`, `completed`, `failed`, `cancelled`, and `unknown`. An observation timeout returns current progress; it does not mean the task failed or was cancelled. Follow the returned state and IDs instead of assuming an absent busy status means success. -Browseable data endpoints: your client can read these without tool calls: +Modern clients can use the MCP Tasks extension for `opencode_run`. Clients without that extension use ordinary tools, including `opencode_fire` and `opencode_check`. Task status is retrieved by polling; this package does not promise to wake an idle assistant with completion notifications. -| URI | Description | -|---|---| -| `opencode://project/current` | Current active project | -| `opencode://config` | Current configuration | -| `opencode://providers` | Providers with models | -| `opencode://agents` | Available agents | -| `opencode://commands` | Available commands | -| `opencode://health` | Server health and version | -| `opencode://vcs` | Version control info | -| `opencode://sessions` | All sessions | -| `opencode://mcp-servers` | MCP server status | -| `opencode://file-status` | VCS file status | - -### Prompts (6) - -Guided workflow templates your client can offer as selectable actions: - -| Prompt | Description | -|---|---| -| `opencode-code-review` | Review diffs from a session | -| `opencode-debug` | Step-by-step debugging workflow | -| `opencode-project-setup` | Get oriented in a new project | -| `opencode-implement` | Have OpenCode build a feature | -| `opencode-best-practices` | Setup, tool selection, monitoring, and pitfalls | -| `opencode-session-summary` | Summarize what happened in a session | - -## Multi-Project Support +Tools retain readable text and provide structured results for clients that consume them. Prompt tools accept optional model variants and OpenCode structured-output formats. See the [generated tools reference](docs/tools.md) and [examples](docs/examples.md). -Every tool accepts an optional `directory` parameter to target a different project. No restarts needed. - -``` -opencode_ask({ directory: "/home/user/mobile-app", prompt: "Add navigation" }) -opencode_ask({ directory: "/home/user/web-app", prompt: "Add auth" }) -``` - -For local servers, use `opencode_project_init` to scaffold a new project directory (or open a preexisting one) before the first call, so the OpenCode server registers it as a project: - -``` -opencode_project_init({ path: "/home/user/new-project" }) -// → "Successfully initialized project directory at: /home/user/new-project" +## Multi-Project Use -opencode_run({ directory: "/home/user/new-project", prompt: "Set up a Vite + React app" }) -``` +Project-scoped tools accept `directory`: an absolute path **on the OpenCode server**. POSIX, Windows drive, and UNC paths are preserved across client operating systems. Relative paths are rejected; OpenCode checks existence and access. -For remote servers, create the directory on the server yourself. `opencode_project_init` operates on the MCP host filesystem. Other tools accept absolute server paths, including Windows and WSL paths, without checking the MCP host's disk. +`opencode_project_init({path: "/absolute/local/project"})` creates or opens a directory on the **MCP host**. For remote OpenCode servers, create the project on that server instead. Authentication tools are global. Resources offer both static reads of the default project and [explicit project/session templates](docs/resources.md). -## Compatibility +Independent sessions do not isolate filesystem changes. Use separate project directories or Git worktrees when running overlapping coding tasks in parallel. -Version 2.0.1 uses `@opencode-ai/sdk` 1.18.31 and has been smoke-tested against OpenCode 1.18.31. Older versions have not been revalidated. When upgrading from 1.x, start a server explicitly or set `OPENCODE_AUTO_SERVE=true` to retain automatic startup. `OPENCODE_SERVE_ARGS` remains unsupported after the SDK migration; use a manually started server for custom CLI flags. +## Configuration -## Environment Variables +All settings are optional; an OpenCode server must already be running by default. -All optional. Only needed if you've changed defaults on the OpenCode server. +| Variable | Purpose | +|---|---| +| `OPENCODE_BASE_URL` | Server endpoint; defaults to `http://127.0.0.1:4096` | +| `OPENCODE_SERVER_USERNAME`, `OPENCODE_SERVER_PASSWORD` | Optional server HTTP authentication | +| `OPENCODE_AUTO_SERVE` | Set to `true` to opt in to launching a local server | +| `OPENCODE_DEFAULT_PROVIDER`, `OPENCODE_DEFAULT_MODEL` | Default prompt provider/model pair | +| `OPENCODE_TOOL_PROFILE` | `full` (default) or a smaller `essential` tool set | +| `OPENCODE_TASK_STORE` | Override the local directory for persisted job records | -| Variable | Default | Description | -|---|---|---| -| `OPENCODE_BASE_URL` | `http://127.0.0.1:4096` | OpenCode server URL | -| `OPENCODE_SERVER_USERNAME` | `opencode` | HTTP basic auth username | -| `OPENCODE_SERVER_PASSWORD` | *(none)* | HTTP basic auth password (enables auth when set) | -| `OPENCODE_AUTO_SERVE` | `false` | Opt in to starting a local `opencode serve` child process if the configured server is unavailable | -| `OPENCODE_DEFAULT_PROVIDER` | *(none)* | Default provider ID when not specified per-tool (e.g. `anthropic`) | -| `OPENCODE_DEFAULT_MODEL` | *(none)* | Default model ID when not specified per-tool (e.g. `claude-sonnet-4-5`) | +See [configuration](docs/configuration.md) for storage, permissions, and client setup. Auto-start only supports local loopback HTTP endpoints; custom OpenCode CLI flags require a manually started server. -## Development +## Development and Verification ```bash -git clone https://github.com/AlaeddineMessadi/opencode-mcp.git -cd opencode-mcp -npm install -npm run build -npm start # run the MCP server -npm run dev # watch mode -npm test # 328 tests +npm ci +npm test +npm run test:coverage ``` -### Smoke Testing - -End-to-end test against a running OpenCode server: +Tests use local fixtures and do not require a model subscription. For controlled live checks against a local OpenCode server: ```bash -npm run build && node scripts/mcp-smoke-test.mjs +npm run build +node scripts/mcp-smoke-test.mjs ``` -## Documentation - -- [Getting Started](docs/getting-started.md): step-by-step setup -- [Configuration](docs/configuration.md): env vars and all client configs -- [Tools Reference](docs/tools.md): all 80 tools in detail -- [Resources](docs/resources.md): 10 MCP resources -- [Prompts](docs/prompts.md): 6 guided workflow templates -- [Examples](docs/examples.md): real workflow examples -- [Architecture](docs/architecture.md): system design and data flow +Live smoke checks use a disposable project and owned session. Inference is opt-in with an explicitly selected provider and model. See [live verification and releases](docs/releasing.md) for scope, skipped capabilities, and publishing checks. -## References - -- [OpenCode](https://opencode.ai/) | [OpenCode Docs](https://opencode.ai/docs/) | [OpenCode Server API](https://opencode.ai/docs/server/) -- [Model Context Protocol](https://modelcontextprotocol.io/) +## Documentation -## License +- [Getting started](docs/getting-started.md) +- [Configuration](docs/configuration.md) +- [Tools reference](docs/tools.md) +- [Resources](docs/resources.md) and [prompts](docs/prompts.md) +- [Examples](docs/examples.md) +- [Architecture](docs/architecture.md) +- [Contributing](CONTRIBUTING.md) and [releasing](docs/releasing.md) -[MIT](LICENSE) +[MIT license](LICENSE) · [OpenCode API](https://opencode.ai/docs/server/) · [Model Context Protocol](https://modelcontextprotocol.io/) diff --git a/docs/architecture.md b/docs/architecture.md index e582c14..c86ecb3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,140 +2,77 @@ ## Overview -opencode-mcp is a **stdio-based MCP server** that bridges MCP clients to the OpenCode headless HTTP API. - -``` -┌─────────────┐ stdio ┌───────────────┐ HTTP ┌─────────────────────────┐ -│ MCP Client │ <────────────> │ opencode-mcp │ <──────────> │ OpenCode Server │ -│ (Claude, │ JSON-RPC │ (this package) │ REST API │ (SDK child process, │ -│ Cursor) │ │ │ │ or external `opencode │ -│ │ │ │ │ serve` you launched) │ -└─────────────┘ └───────────────┘ └─────────────────────────┘ +```text +MCP client ── stdio ── opencode-mcp ── HTTP / SSE ── OpenCode server + │ + └── local job metadata and result storage ``` -## Project Structure - -``` -src/ -├── index.ts Main entry point: creates server, registers everything -├── server-manager.ts Probe server + opt-in child-process start via SDK -├── client.ts HTTP client with retry, SSE, error categorization -├── helpers.ts Response formatting + tool annotation constants -├── resources.ts MCP Resources (10 browseable data endpoints) -├── prompts.ts MCP Prompts (6 guided workflow templates) -└── tools/ - ├── workflow.ts High-level workflow tools (13): start here - ├── session.ts Session lifecycle management (20) - ├── message.ts Message/prompt operations (6) - ├── file.ts File and search operations (6) - ├── tui.ts TUI remote control (9) - ├── config.ts Configuration management (3) - ├── provider.ts Provider and authentication (6) - ├── misc.ts System, agents, LSP, MCP, logging (12) - ├── events.ts SSE event polling (1) - ├── global.ts Health check (1) - └── project.ts Project operations (3): list, init, current -``` +opencode-mcp is a stdio bridge. OpenCode owns coding sessions and model execution. The MCP process owns tool registration, response formatting, job observation, and its local job records. A remote OpenCode server can be used through HTTP; the MCP-facing transport remains stdio. -## Three MCP Primitives +## Main Components -| Primitive | Count | Purpose | -|---|---|---| -| **Tools** | 80 | Actions the LLM can take | -| **Resources** | 10 | Data the LLM can browse | -| **Prompts** | 6 | Guided multi-step workflows | +| Area | Responsibility | +|---|---| +| Entry point and MCP adapter | Register the catalog, select a profile, negotiate protocol support, manage stdio | +| `client.ts` | SDK-backed HTTP requests, error handling, bounded retries, SSE, directory routing | +| `server-manager.ts` | Health probe, optional local child startup, owned-child shutdown | +| `async.ts` | Shared request deadlines, cancellation, and abortable waits | +| Job service and job tools | Persist handles/results, correlate completion, observe, cancel, and request required input | +| `helpers.ts` | Input helpers, result formatting, redaction, truncation | +| `tools/` | OpenCode API tools and combined workflows | +| `resources.ts`, `prompts.ts` | Data reads and reusable workflow instructions | -## Key Design Decisions +The full catalog is available by default. The essential profile advertises a smaller group of common workflows. Both profiles use the same implementations and OpenCode permission model. -### Layered Tool Architecture +## Async State and Recovery -Tools are in two layers: +The async workflow distinguishes submission from completion. A job can be `accepted`, `running`, `input_required`, `completed`, `failed`, `cancelled`, or `unknown`. Completion is correlated with the submitted work and an assistant message; an omitted idle entry in OpenCode's status map is not sufficient evidence by itself. -- **Low-level**: 1:1 mapping to OpenCode API endpoints (session, message, file, etc.) -- **Workflow**: Composite operations that combine multiple calls (`opencode_ask`, `opencode_run`, `opencode_fire`, etc.) +`opencode_fire` returns promptly after dispatch. `opencode_check` observes work; `opencode_wait` and `opencode_run` wait within a bounded observation period. Expiring that period returns progress with a timeout indication. It does not abort the OpenCode session. Requests for permission or answers surface as `input_required` so the caller can respond explicitly. Cancellation of an observation stops waiting; use the job-cancel or session-abort tool to stop remote work. -The workflow layer drastically reduces tool calls. Instead of "create session, send message, parse response", it's one `opencode_ask` call. For long-running tasks, `opencode_run` handles session creation + async dispatch + polling in one call. `opencode_fire` + `opencode_check` enables background work with lightweight monitoring. +Local job handles and results persist across MCP restarts, with a default 24-hour retention period measured from creation. Expiring a handle does not cancel the remote session. This supports rediscovery and resumed observation of existing OpenCode work, not checkpointing or restarting model execution. An ambiguous submission is not automatically resubmitted, because that could run a task twice. Backend failures remain errors rather than becoming successful completion. -### Tool Annotations +Background execution survives an MCP disconnect only while the OpenCode server remains running. An externally managed OpenCode server is left running. A child launched by `OPENCODE_AUTO_SERVE=true` is closed with the MCP process, so do not rely on that child for work that must continue after disconnect. -Every tool carries MCP annotations (`readOnlyHint`, `destructiveHint`) so clients can make informed decisions about safety. Read-only tools like `opencode_check` and `opencode_context` are annotated as safe; destructive tools like `opencode_instance_dispose` are flagged. +### Native MCP Tasks -### Smart Response Formatting +On protocol revision `2026-07-28`, clients advertising `io.modelcontextprotocol/tasks` in their per-request capabilities receive a native task handle from `opencode_run`. `tasks/get` retrieves status and the eventual result; `tasks/update` supplies explicit input responses; `tasks/cancel` requests cancellation. This follows the [July 2026 Tasks extension schema](https://github.com/modelcontextprotocol/ext-tasks/blob/main/schema/2026-07-28/schema.ts), rather than the earlier experimental task API. A small stdio adapter handles these extension operations; standard and legacy protocol messages use the MCP SDK v2 transport. Ordinary `fire`/`check`/`wait` calls remain available to clients without Tasks support. -Raw API responses are deeply nested JSON. The `helpers.ts` module transforms these into human-readable text: +No task status notifications are advertised. Clients poll explicitly. There is no remote HTTP MCP transport or guarantee that a notification wakes an idle model. -- Message parts -> extracted text, tool call summaries -- Diffs -> formatted with file paths, add/delete counts -- Session lists -> bullet-point format with titles and IDs -- Large responses -> auto-truncated at 50K characters +### Interactive Input -### Robust HTTP Client +Job input can present pending permission or question requests through a negotiated modern client interaction. Explicit permission and question tools provide a fallback. Replies are validated and forwarded to OpenCode; the bridge does not approve permissions on the user's behalf. -`OpenCodeClient` handles: +## Request Scope and Lifecycle -- **Automatic retry**: Exponential backoff for 429, 502, 503, 504 -- **Error categorization**: `OpenCodeError` with `.isTransient`, `.isNotFound`, `.isAuth` -- **204 No Content**: Properly handled -- **SSE streaming**: Async generator for Server-Sent Events -- **Directory validation**: Paths are normalized (resolved to absolute, trailing slashes removed) and validated (must exist on disk) before being sent as the `x-opencode-directory` header -- **Lazy reconnection**: If all retries fail due to connection errors (`ECONNREFUSED`, `ENOTFOUND`, etc.) and `autoServe` is enabled, the client attempts to restart the OpenCode server and retry once (up to 3 reconnection attempts per MCP session) +Project-scoped calls include an absolute `directory` on the OpenCode server. Validation rejects relative paths and NUL/CR/LF bytes, preserving POSIX, Windows drive, and UNC paths without consulting the MCP host's filesystem. OpenCode resolves actual existence and access. -### Default Provider/Model +Authentication routes are global. `opencode_project_init` is a separate local-filesystem operation: it accepts `path`, checks protected roots and symlinks, creates the directory if necessary, then opens it through OpenCode. It does not create remote projects. -Tools that accept `providerID` and `modelID` apply a three-tier resolution: +On startup the process probes the configured endpoint. With automatic startup enabled, the SDK can launch `opencode serve` on a local loopback HTTP endpoint. Concurrent starts are coalesced by endpoint. Shutdown handlers stop only owned child processes; externally managed servers remain running. -1. **Explicit params**: If both are passed to the tool call, use them -2. **Env-var defaults**: If `OPENCODE_DEFAULT_PROVIDER` and `OPENCODE_DEFAULT_MODEL` are set, use them as fallback -3. **Server default**: If neither is available, let the OpenCode server decide (may result in empty responses if no provider is configured) +## Transport and Observation Costs -This is implemented via `applyModelDefaults()` in `helpers.ts`, called from all 8 tools that accept model params. +HTTP requests share a total deadline across attempts and use cancellation-aware waits. Project event polling forwards the directory; explicitly global event polling rejects a directory to avoid ambiguous scope. Polling preserves partial errors. -### Auto-Start +Job observation reads bounded recent messages to correlate completion. It uses session summary counts rather than downloading full diffs on each check. This is not a promise of zero network or history reads: the first fresh observation still contacts OpenCode; already persisted terminal results can be returned locally. -On startup, MCP probes `OPENCODE_BASE_URL/global/health` and attaches to an existing server. Startup is opt-in: `OPENCODE_AUTO_SERVE=true` allows `createOpencodeServer()` from the SDK to spawn the `opencode serve` executable as a child process. OpenCode must be on `PATH`; it binds its own HTTP port and inherits the process environment, including server authentication. Only loopback HTTP endpoints support automatic startup. +## Results, Resources, and Prompts -Shutdown handlers for stdin `end`/`close`, `SIGINT`, `SIGTERM`, `SIGHUP` and `exit` close the SDK-owned child. External servers remain running. The same opt-in setting controls reconnection attempts that may launch another server. +Tools expose behavior annotations and an output schema. They retain readable text for older clients and provide `structuredContent` for machine consumption. Generic JSON responses use a `data` field; workflow responses add fields such as job/session IDs and state. Consumers should inspect `isError` and structured state rather than infer success from arbitrary text. -Concurrent `ensureServer()` calls are coalesced per `baseUrl` via an in-flight `Map` so two simultaneous tool calls during cold-start can't race into `EADDRINUSE`. Calls targeting different baseUrls each get their own startup promise. +Sensitive configuration/provider fields are redacted. Oversized serialized responses use a valid JSON truncation envelope with a text preview instead of slicing JSON into an invalid document. -## Data Flow - -### Tool Call - -``` -1. MCP Client sends JSON-RPC tool call via stdio -2. McpServer dispatches to registered handler -3. Handler builds HTTP request -4. OpenCodeClient makes HTTP call to OpenCode -5. Response formatted by helpers.ts -6. Formatted text returned as MCP tool result -7. McpServer sends JSON-RPC response via stdio -``` - -### Resource Read - -``` -1. Client requests resource by URI (e.g. opencode://health) -2. Handler fetches from OpenCode via HTTP -3. Data returned as resource content (JSON) -``` - -### SSE Events - -``` -1. opencode_events_poll opens SSE connection to /event -2. Events collected for specified duration -3. Connection closed, events formatted and returned -``` +Static resources use the default project. Project/session resource templates encode their explicit server path in the URI. Resources have no subscription support. Prompts guide common workflows but do not themselves execute tools. -## Registration Pattern +## Verification -Each tool group is a file exporting a `register*` function that receives `(server, client)`. New tool groups can be added without touching the entry point. +Unit tests cover formatting, validation, job state, and tool behavior. Local HTTP fixtures exercise the SDK transport; stdio process tests cover protocol behavior and lifecycle. The live smoke runner creates a disposable Git project and mutates only its owned session, with inference disabled by default. See [releasing](releasing.md) for exact live-check scope and artifact verification. -### Permission Handling +### Shared-session cancellation -In headless mode, OpenCode may pause sessions waiting for tool-use permissions (e.g. file writes, shell commands). This blocks progress silently. The MCP server addresses this with: +OpenCode aborts a whole session. Job cancellation refuses a known newer turn, but another client can submit work between that check and the abort request. Use the default dedicated session per job when cancellation must not affect concurrent work. -- **`opencode_permission_list`**: Lists all pending permission requests across sessions so the LLM can detect and unblock stuck sessions -- **`opencode_session_permission`**: Replies to a specific permission request with `once`, `always`, or `reject` -- **Recommended config**: Set `"permission": "allow"` in `opencode.json` or call `opencode_config_update({ config: { permission: "allow" } })` at runtime to auto-approve all tool use in headless mode +A native `tasks/cancel` acknowledgement records cancellation intent; it does not prove execution has stopped. Poll `tasks/get` for the eventual state, especially after a lost backend response. diff --git a/docs/configuration.md b/docs/configuration.md index f000a70..a271a91 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,261 +2,154 @@ ## Environment Variables -All environment variables are **optional**. By default, an OpenCode server must already be running on port 4096. +An OpenCode server must already be running unless automatic startup is explicitly enabled. Node.js 22 or newer is required for version 3.0.0 and later. -| Variable | Description | Default | Required | -|---|---|---|---| -| `OPENCODE_BASE_URL` | URL of the OpenCode headless server | `http://127.0.0.1:4096` | No | -| `OPENCODE_SERVER_USERNAME` | HTTP basic auth username | `opencode` | No | -| `OPENCODE_SERVER_PASSWORD` | HTTP basic auth password | *(none: auth disabled)* | No | -| `OPENCODE_AUTO_SERVE` | Opt in to starting a separate local `opencode serve` child process | `false` | No | -| `OPENCODE_DEFAULT_PROVIDER` | Default provider ID when not specified per-tool | *(none)* | No | -| `OPENCODE_DEFAULT_MODEL` | Default model ID when not specified per-tool | *(none)* | No | +| Variable | Default | Description | +|---|---|---| +| `OPENCODE_BASE_URL` | `http://127.0.0.1:4096` | OpenCode HTTP endpoint | +| `OPENCODE_SERVER_USERNAME` | `opencode` | Username when HTTP authentication is enabled | +| `OPENCODE_SERVER_PASSWORD` | unset | HTTP password; set matching credentials on OpenCode and MCP | +| `OPENCODE_AUTO_SERVE` | `false` | Set exactly `true` to allow a local child server to start | +| `OPENCODE_DEFAULT_PROVIDER` | unset | Default prompt provider ID | +| `OPENCODE_DEFAULT_MODEL` | unset | Default prompt model ID; configure both defaults together | +| `OPENCODE_TOOL_PROFILE` | `full` | `full` or `essential`; changes the advertised tool set | +| `OPENCODE_TASK_STORE` | see below | Root directory for persisted job records | -### Notes +Choose provider/model IDs from `opencode_setup` and `opencode_provider_models`. Authentication credentials are global to OpenCode; `directory` does not make provider credentials project-specific. -- **Authentication is disabled by default.** It only activates when `OPENCODE_SERVER_PASSWORD` is set on both the OpenCode server and the MCP server. -- **Username and password are both optional.** The default username is `opencode`, matching the OpenCode server's default. You only need to set these if you've explicitly enabled auth on the server. -- **The base URL** should point to where `opencode serve` is listening. If running on the same machine with default settings, you don't need to set this. -- **Default provider/model** are optional. When set, tools that accept `providerID`/`modelID` will use these as fallbacks when not specified per-call. Both must be set together. Example: `OPENCODE_DEFAULT_PROVIDER=anthropic` + `OPENCODE_DEFAULT_MODEL=claude-sonnet-4-5`. -- **Directory validation**: `directory` is an absolute path on the OpenCode server. POSIX, Windows drive and UNC paths are preserved on every client OS. Relative paths and NUL/CR/LF bytes are rejected. Existence, permissions and symlinks are resolved by OpenCode. `opencode_project_init` is a local filesystem tool and cannot create directories on a remote server. +The `essential` profile keeps the common delegation, observation, and required-input workflows available with fewer tool definitions. Select `full` for low-level API tools, TUI control, or provider administration. The profile is a discovery choice, not a security boundary: coding workflows can still modify files or run commands through OpenCode according to its permissions. -## MCP Client Configurations +Job records hold local operational metadata and results, independent of OpenCode's session storage. The default root is `$XDG_STATE_HOME/opencode-mcp/tasks` (falling back to `~/.local/state/opencode-mcp/tasks`); Windows uses `%LOCALAPPDATA%/opencode-mcp/tasks`. Records are partitioned by server and caller scope. Records expire 24 hours after creation; expiry removes the handle and does not abort the OpenCode session. Keep the task store private to your user account and persist it if the MCP process is ephemeral. See [architecture](architecture.md) for recovery behavior and retention. -Below are complete configuration examples for every supported MCP client. All examples assume the OpenCode server is running on the default `http://127.0.0.1:4096` with no auth. +## Project Scope -### Claude Desktop +For project-scoped tools, `directory` must be an absolute path on the OpenCode server. POSIX, Windows drive, and UNC paths are preserved on every client OS; relative paths and NUL/CR/LF are rejected. OpenCode resolves existence, access, and symlinks. Global authentication tools omit `directory`. -**Config file location:** -- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` -- Windows: `%APPDATA%\Claude\claude_desktop_config.json` -- Linux: `~/.config/Claude/claude_desktop_config.json` +`opencode_project_init` operates on the MCP host filesystem and accepts `path`. It cannot create a directory on a remote OpenCode host. Static resources read the server's default project; [resource templates](resources.md) explicitly select a project or session. -```json -{ - "mcpServers": { - "opencode": { - "command": "npx", - "args": ["-y", "opencode-mcp"] - } - } -} -``` +## MCP Client Configurations -### Claude Code (CLI) +These examples launch the published npm package. When testing a source build, replace the command with `node` and the arguments with the absolute path to your built `dist/index.js`. Modern MCP extensions are negotiated; support for tools does not imply that a client supports tasks or interactive input. + +### Claude Code ```bash -# Add globally +# Current project, private to your account claude mcp add opencode -- npx -y opencode-mcp -# Add with custom env -claude mcp add opencode --env OPENCODE_BASE_URL=http://192.168.1.10:4096 -- npx -y opencode-mcp +# Available across projects +claude mcp add opencode --scope user -- npx -y opencode-mcp -# Remove -claude mcp remove opencode +# Custom endpoint +claude mcp add opencode --env OPENCODE_BASE_URL=http://127.0.0.1:8080 -- npx -y opencode-mcp ``` -### Cursor - -**Config file:** `.cursor/mcp.json` in your project root - -```json -{ - "mcpServers": { - "opencode": { - "command": "npx", - "args": ["-y", "opencode-mcp"] - } - } -} -``` +See [Claude Code MCP configuration](https://code.claude.com/docs/en/mcp) for scopes and environment expansion. -### Windsurf +### Claude Desktop, Cursor, and Windsurf -**Config file:** `~/.windsurf/mcp.json` +Use the following server entry in the client's MCP configuration: ```json { "mcpServers": { "opencode": { "command": "npx", - "args": ["-y", "opencode-mcp"] + "args": ["-y", "opencode-mcp"], + "env": { "OPENCODE_TOOL_PROFILE": "essential" } } } } ``` -### VS Code: GitHub Copilot +- **Claude Desktop:** open the app's developer settings to edit its MCP configuration. +- **Cursor:** use the MCP settings UI or the project's `.cursor/mcp.json`; see [Cursor MCP documentation](https://cursor.com/docs/mcp). +- **Windsurf:** edit `~/.codeium/windsurf/mcp_config.json` through its MCP settings. See [Windsurf MCP documentation](https://docs.windsurf.com/windsurf/cascade/mcp). -**Config file:** `.vscode/settings.json` or user `settings.json` +### VS Code / GitHub Copilot + +Use `.vscode/mcp.json` in the workspace, or run **MCP: Open User Configuration** for a user-level file. The key is `servers`: ```json { - "github.copilot.chat.mcp.servers": [ - { - "name": "opencode", + "servers": { + "opencode": { "type": "stdio", "command": "npx", "args": ["-y", "opencode-mcp"] } - ] + } } ``` -### Cline (VS Code extension) - -Cline manages MCP servers through its own settings UI. Add a new server with: - -- **Command:** `npx` -- **Args:** `-y opencode-mcp` -- **Transport:** stdio +See [VS Code MCP setup](https://code.visualstudio.com/docs/agent-customization/mcp-servers). Client-specific secret inputs can be used instead of committing credentials in JSON. ### Continue -**Config file:** `.continue/config.json` in your project root or `~/.continue/config.json` globally +Create `.continue/mcpServers/opencode.yaml` in your workspace: -```json -{ - "mcpServers": { - "opencode": { - "command": "npx", - "args": ["-y", "opencode-mcp"] - } - } -} +```yaml +name: OpenCode +version: 1.0.0 +schema: v1 +mcpServers: + - name: opencode + command: npx + args: ["-y", "opencode-mcp"] ``` +Use Agent mode. See [Continue MCP configuration](https://docs.continue.dev/reference) and [workspace configuration](https://docs.continue.dev/guides/configuring-models-rules-tools). + ### Zed -**Config file:** `~/.config/zed/settings.json` or project `settings.json` +Use **Settings → AI → MCP Servers → Add Local Server**, or edit the settings file: ```json { "context_servers": { - "opencode": { - "command": { - "path": "npx", - "args": ["-y", "opencode-mcp"] - } - } - } -} -``` - -### Amazon Q - -**Config file:** VS Code `settings.json` - -```json -{ - "amazon-q.mcp.servers": [ - { - "name": "opencode", - "type": "stdio", - "command": "npx", - "args": ["-y", "opencode-mcp"] - } - ] -} -``` - -### With authentication (optional) - -Add `env` to any config above. This is only needed if you've enabled auth on the OpenCode server: - -```json -{ - "mcpServers": { "opencode": { "command": "npx", "args": ["-y", "opencode-mcp"], - "env": { - "OPENCODE_BASE_URL": "http://127.0.0.1:4096", - "OPENCODE_SERVER_USERNAME": "myuser", - "OPENCODE_SERVER_PASSWORD": "mypass" - } - } - } -} -``` - -### With global install (instead of npx) - -If you prefer a global install for faster startup: - -```bash -npm install -g opencode-mcp -``` - -Then use `opencode-mcp` directly in your config: - -```json -{ - "mcpServers": { - "opencode": { - "command": "opencode-mcp" + "env": {} } } } ``` -## Permissions (Headless Mode) +See [Zed MCP configuration](https://zed.dev/docs/ai/mcp). The command is a string; the earlier nested `command.path` example is obsolete. -In headless mode, OpenCode may pause sessions waiting for permission to use tools (file writes, shell commands, etc.). This blocks progress silently. +### Amazon Q Developer -**Recommended: Auto-allow all permissions** by adding to your `opencode.json`: +Open the tools/MCP panel, add a **STDIO** server named `opencode`, set command `npx`, and arguments `-y opencode-mcp`. Choose local or global scope. The current IDE UI stores these settings in `.amazonq/default.json` or `~/.aws/amazonq/default.json`; see [Amazon Q configuration](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/mcp-ide.html). -```json -{ - "permission": "allow" -} -``` +### Cline -Or set it at runtime: +Use Cline's MCP server settings to add a stdio server with command `npx` and arguments `-y opencode-mcp`. -``` -opencode_config_update({ config: { permission: "allow" } }) -``` +## Required Input and Permissions -If you prefer manual control, use the permission tools to detect and unblock stuck sessions: +OpenCode can pause a session for permission or a question. Async observation reports `input_required`; it does not treat the pause as successful completion. Use the returned job ID with `opencode_job_input`, or use the lower-level tools: -| Tool | Description | +| Tool | Purpose | |---|---| -| `opencode_permission_list` | List all pending permission requests across sessions | -| `opencode_session_permission` | Reply to a permission request (`once`, `always`, `reject`) | - -## Auto-Start - -By default, the MCP server connects to an existing OpenCode server. Start `opencode serve --port 4096` yourself, or start the TUI with `opencode --port 4096` to share its server. Set `OPENCODE_BASE_URL` if using another port. +| `opencode_permission_list` | Inspect pending permission requests in a project | +| `opencode_session_permission` | Reply `once`, `always`, or `reject` | +| `opencode_question_list` | Inspect pending questions | +| `opencode_question_reply` | Provide selected answers | +| `opencode_question_reject` | Dismiss a pending question | -To explicitly allow MCP to launch a separate local server: - -```json -{ - "env": { - "OPENCODE_AUTO_SERVE": "true" - } -} -``` +When supported by the client, job input can present an interactive request. These tools never silently approve permissions. Set a deliberate permission policy in the project's `opencode.json`; blanket `"permission": "allow"` is an optional policy choice, not a prerequisite for headless use. -## Manual OpenCode Server Setup +## Server Lifecycle Start a server explicitly: ```bash -# Default (no auth, port 4096) -opencode serve - -# Custom port -opencode serve --port 8080 - -# With authentication (optional) -OPENCODE_SERVER_USERNAME=myuser OPENCODE_SERVER_PASSWORD=mypass opencode serve +opencode serve --hostname 127.0.0.1 --port 4096 ``` -The server exposes an OpenAPI 3.1 spec at `http://:/doc`. - +Or share the TUI's server by starting it with `opencode --port 4096`. For custom flags, start OpenCode manually. `OPENCODE_SERVE_ARGS` is unsupported by the SDK launcher. -When other TUI instances are running, prefer a shared, explicitly configured server. Issue #18 reports hangs with a second server sharing the same OpenCode storage. The underlying storage cause has not been confirmed. The MCP does not scan processes or choose an arbitrary TUI instance. Auto-start only supports loopback HTTP endpoints, and only child processes launched by this MCP are stopped on disconnect. +To opt in to a separate local child server, add `"OPENCODE_AUTO_SERVE": "true"` under your MCP server's `env`. Automatic startup accepts loopback HTTP endpoints only and shuts down only the child it launched. An existing external server remains running when the MCP client disconnects. -`OPENCODE_SERVE_ARGS` is not supported by the SDK launcher. Start OpenCode manually when custom CLI flags are needed. +When other TUI instances are running, prefer a shared explicitly configured server. [Issue #18](https://github.com/AlaeddineMessadi/opencode-mcp/issues/18) reports hangs when a second server shares OpenCode storage; the underlying cause has not been confirmed. MCP does not scan processes to select a TUI. diff --git a/docs/examples.md b/docs/examples.md index a4dded8..492c1bd 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,194 +1,157 @@ # Usage Examples -Real-world examples of using opencode-mcp tools from any MCP client. +These examples describe version 3.0.0. Replace provider/model placeholders with IDs discovered from `opencode_setup` and `opencode_provider_models`. All `directory` values refer to absolute paths on the OpenCode server. -## Quick Question +## Quick Question and Follow-Up -Ask OpenCode something and get an answer in one call: - -```json +```javascript opencode_ask({ - "prompt": "Explain the authentication flow in this project", - "providerID": "anthropic", - "modelID": "claude-opus-4-6" + directory: "/home/user/project", + prompt: "Explain the authentication flow", + providerID: "", + modelID: "" }) -``` - -## Build a Feature -Have OpenCode implement something and wait for it to finish: - -```json -opencode_run({ - "prompt": "Add input validation to POST /api/users. Validate email format, non-empty name, and positive integer age. Return 400 with descriptive errors.", - "providerID": "anthropic", - "modelID": "claude-opus-4-6", - "maxDurationSeconds": 300 +opencode_reply({ + directory: "/home/user/project", + sessionId: "", + prompt: "Which edge cases should we test?", + providerID: "", + modelID: "" }) ``` -Then review what it did: +If both default provider/model environment variables are configured, you may omit them from individual calls. An optional `variant` selects an available model variant; use a value supported by the chosen provider/model. -```json -opencode_review_changes({ "sessionId": "" }) -``` - -## Multi-Turn Conversation +## Background Work and Recovery -Start a session and iterate: - -**1.** `opencode_ask`: -```json -{ "prompt": "What testing framework does this project use?", "title": "Testing exploration" } -``` +```javascript +opencode_fire({ + directory: "/home/user/project", + prompt: "Add input validation to POST /api/users and run relevant tests", + providerID: "", + modelID: "" +}) +// Save structuredContent.jobId, sessionId, and messageId. -**2.** `opencode_reply`: -```json -{ "sessionId": "", "prompt": "Show me an example of writing a new test" } -``` +opencode_check({ + directory: "/home/user/project", + sessionId: "" +}) -**3.** `opencode_reply`: -```json -{ "sessionId": "", "prompt": "Now add a test for UserService.create" } +opencode_job_get({ jobId: "" }) ``` -## Background Tasks (Fire-and-Forget) +After an MCP restart, use `opencode_job_list` to rediscover retained jobs. A durable handle helps resume observation; it does not restart a stopped OpenCode server or replay an interrupted prompt. -Dispatch a long-running task and keep working on something else: +Use `opencode_job_cancel({jobId: ""})` to request cancellation. For a session without a recorded job, use `opencode_session_abort` with its session ID and directory. -**1. Fire the task** — `opencode_fire`: -```json -{ - "prompt": "Refactor the entire authentication module to use JWT tokens", - "providerID": "anthropic", - "modelID": "claude-opus-4-6", - "title": "JWT refactor" -} -``` -Returns immediately with a session ID. +## Wait Without Losing the Handle -**2. Check progress** — `opencode_check`: -```json -{ "sessionId": "" } -``` -Returns: status (running/idle), todo progress (e.g. "3/7 done, current: Add JWT middleware"), files changed count. - -**3. Get full results when done** — `opencode_conversation`: -```json -{ "sessionId": "" } +```javascript +opencode_run({ + directory: "/home/user/project", + prompt: "Add tests for expired login tokens", + providerID: "", + modelID: "", + maxDurationSeconds: 120 +}) ``` -## Build a Full App +The response contains the current state. If `timedOut` is true while work continues, keep the returned IDs and call `opencode_job_get`, `opencode_check`, or `opencode_wait`. Do not submit a second copy of the task merely because the wait expired. -Chain multiple tools to build an entire project: +For clients implementing the native MCP Tasks extension, request task execution for `opencode_run` through the client's task API. The native `taskId` identifies the same recorded job as its ordinary `jobId`. Clients without that extension can use the tool sequence above; no push notification or automatic assistant wake-up is promised. -```json -// 1. Set up the project -opencode_run({ - "prompt": "Create a new Express.js API with TypeScript, SQLite, and Vitest. Include health check, CRUD for users, and error handling middleware.", - "title": "Build API", - "maxDurationSeconds": 600 -}) - -// 2. Add features in parallel -opencode_fire({ "prompt": "Add JWT authentication with login/register endpoints", "title": "Add auth" }) -opencode_fire({ "prompt": "Add rate limiting middleware and request logging", "title": "Add middleware" }) +## Respond to Required Input -// 3. Check both are done -opencode_check({ "sessionId": "" }) -opencode_check({ "sessionId": "" }) +When a job reports `input_required`, inspect its requests. July 2026 MCP clients advertising form elicitation can show an interactive input form. Legacy clients use the explicit response arguments below: -// 4. Review all changes -opencode_review_changes({ "sessionId": "" }) -opencode_review_changes({ "sessionId": "" }) +```javascript +opencode_job_input({ jobId: "" }) ``` -## Get Project Context +Or send an explicit response after the user has chosen it: -Understand a project you've never seen: - -```json -opencode_context({}) +```javascript +opencode_job_input({ + jobId: "", + responses: [ + { id: "", kind: "permission", reply: "once" }, + { id: "", kind: "question", answers: [[""]] } + ] +}) ``` -Returns: project info, VCS details (branch, status), configuration, and available agents. +Question answers are an array per question, each containing the selected answers. To reject a question, send `{id: "", kind: "question", reject: true}`. Permission replies are `once`, `always`, or `reject`; choose them according to the user's decision. -## Code Review +Lower-level callers can use `opencode_question_list`, `opencode_question_reply({requestId, answers, directory})`, and `opencode_question_reject({requestId, directory})`. Existing `opencode_permission_list` and `opencode_session_permission` remain available. -Review changes from a coding session: +## Request Structured Model Output -**1.** Find the session — `opencode_sessions_overview`: -```json -{} -``` - -**2.** Review the diff — `opencode_review_changes`: -```json -{ "sessionId": "" } +```javascript +opencode_run({ + directory: "/home/user/project", + prompt: "Summarize the project using the requested schema", + providerID: "", + modelID: "", + format: { + type: "json_schema", + schema: { + type: "object", + properties: { summary: { type: "string" } }, + required: ["summary"], + additionalProperties: false + }, + retryCount: 2 + } +}) ``` -**3.** Read the conversation — `opencode_conversation`: -```json -{ "sessionId": "" } -``` +`format` is forwarded to OpenCode. `{type: "text"}` requests ordinary text. Model-generated structured output and MCP `structuredContent` serve different purposes: the former shapes the model's answer; the latter carries the tool's machine-readable status and result fields. -## Search the Codebase +OpenCode produces the answer by calling its `StructuredOutput` tool. If the project's permission policy denies all tools, explicitly allow this tool in `opencode.json`: ```json -// Find TODOs and FIXMEs -opencode_find_text({ "pattern": "TODO|FIXME|HACK" }) - -// Find config files -opencode_find_file({ "query": "config" }) - -// Find a function or class -opencode_find_symbol({ "query": "handleAuth" }) +{ "permission": { "*": "deny", "StructuredOutput": "allow" } } ``` -## Multi-Project Workflow +This policy is suitable for an output-only task. Add permissions for the reads or edits your task needs. The MCP does not change permissions automatically. Denying `StructuredOutput` can leave a JSON-schema prompt running without producing an answer; an observation timeout does not stop inference. Explicitly abort an owned session when abandoning a run. -Work on multiple projects from one client: +OpenCode 1.18.31 can return HTTP 400 when reading history containing a persisted response format. When a job/message ID is known, async observation retries a read of only the latest message and accepts a completed answer only if its parent matches that ID. If it cannot prove completion, it reports `unknown` and does not expose pending inputs from incomplete history. Keep polling the same job rather than resubmitting. Full history and cancellation ownership checks can still fail on the upstream serialization error; use the low-level session abort only after confirming that you own its current work. -```json -// Mobile app -opencode_ask({ - "directory": "/home/user/projects/mobile-app", - "prompt": "Set up React Navigation with a tab navigator" +## Independent Projects and Parallel Work + +```javascript +opencode_fire({ + directory: "/home/user/worktrees/auth", + prompt: "Add authentication tests", + providerID: "", + modelID: "" }) -// Web app (same server, different project) -opencode_ask({ - "directory": "/home/user/projects/web-app", - "prompt": "Add authentication to the Next.js app" +opencode_fire({ + directory: "/home/user/worktrees/docs", + prompt: "Update the API documentation", + providerID: "", + modelID: "" }) ``` -## Provider Management +Create the directories or worktrees first. Sessions alone do not isolate file changes; independent worktrees avoid overlapping edits. `opencode_project_init` can initialize a local directory on the MCP host, while remote directories must be created on the OpenCode host. -```json -// Check what's available -opencode_provider_list({}) - -// Set an API key (one-time, global) -opencode_auth_set({ - "providerId": "anthropic", - "type": "api", - "key": "sk-ant-..." +## Review Completed Work + +```javascript +opencode_review_changes({ + directory: "/home/user/project", + sessionId: "" }) -// Test that it works -opencode_provider_test({ "providerId": "anthropic" }) +opencode_conversation({ + directory: "/home/user/project", + sessionId: "", + limit: 10 +}) ``` -## Using Prompts - -MCP prompts are guided workflow templates your client offers as selectable actions. - -| Prompt | Arguments | What it does | -|---|---|---| -| `opencode-code-review` | `sessionId` | Reviews diffs for correctness, style, performance, security | -| `opencode-debug` | `issue`, `context?` | Step-by-step debugging: finds files, reads code, identifies root cause | -| `opencode-project-setup` | *(none)* | Reads README, configs, entry points, summarizes the project | -| `opencode-implement` | `description`, `requirements?` | Sends to OpenCode's build agent, reviews changes, reports results | -| `opencode-best-practices` | *(none)* | Guides on setup, tool selection, monitoring, and common pitfalls | -| `opencode-session-summary` | `sessionId` | Summarizes discussion, actions, files modified, remaining work | +Review the changes and test results before accepting the work. Resources and prompts offer additional entry points; see the [resources reference](resources.md), [prompts](prompts.md), and [generated tool schemas](tools.md). diff --git a/docs/getting-started.md b/docs/getting-started.md index 2230222..ca060a1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,126 +1,72 @@ # Getting Started -Set up opencode-mcp in under 2 minutes. - ## Prerequisites -- **Node.js** >= 18 ([download](https://nodejs.org/)) -- **OpenCode** installed ([opencode.ai](https://opencode.ai/)) - - `curl -fsSL https://opencode.ai/install | bash` - - or `npm i -g opencode-ai` - - or `brew install sst/tap/opencode` -- An **MCP-compatible client** (Claude Desktop, Claude Code, Cursor, Windsurf, etc.) +- Node.js **22 or newer** for version 3.0.0 and later. +- [OpenCode](https://opencode.ai/docs/) installed and a provider configured for model calls. +- An MCP client capable of launching a stdio server. -## Step 1: Add to Your Client +These instructions cover version 3.0.0. Review the [migration notes](../CHANGELOG.md#300---2026-09-16) when upgrading from 2.x. -**Claude Code:** +## 1. Start OpenCode -```bash -claude mcp add opencode -- npx -y opencode-mcp -``` +From your project directory: -**Claude Desktop / Cursor / Windsurf / Cline / Continue**: add to your MCP config file: - -```json -{ - "mcpServers": { - "opencode": { - "command": "npx", - "args": ["-y", "opencode-mcp"] - } - } -} +```bash +opencode serve --hostname 127.0.0.1 --port 4096 ``` -See [Configuration](configuration.md) for all client configs (VS Code Copilot, Zed, Amazon Q, OpenCode itself, etc.). +Alternatively, share your TUI's server by starting it with `opencode --port 4096`. Automatic startup is disabled by default. Opt in with `OPENCODE_AUTO_SERVE=true` only when you want a separate child server. -## Step 2: Start OpenCode and Restart Your Client +## 2. Add MCP to Your Client -Start `opencode serve --hostname 127.0.0.1 --port 4096`, then restart your MCP client. If you use the TUI, start it with `opencode --port 4096` and share that server instead. +For Claude Code: -Automatic startup is disabled by default. Set `OPENCODE_AUTO_SERVE=true` only if you want MCP to launch a separate local OpenCode child process. +```bash +claude mcp add opencode -- npx -y opencode-mcp +``` -## Step 3: Verify +Other clients have different configuration shapes. Follow the [client configuration guide](configuration.md); for example VS Code uses `.vscode/mcp.json` with a `servers` object, while Continue uses YAML. -Ask your client to run a tool: +For this source checkout, run `npm ci` and `npm run build`, then set your MCP command to `node` with the absolute path to `dist/index.js` as its argument. Restart the MCP connection after rebuilding. -- *"Use opencode_setup to check server status"* -- *"Use opencode_context to get project info"* -- *"Use opencode_ask to explain this project"* +## 3. Verify Setup and Choose a Model -If it returns data from OpenCode, everything is working. +Ask your client to: -## What's Available +1. Call `opencode_setup` to inspect server health and configured providers. +2. Call `opencode_provider_models` for a configured provider. +3. Call `opencode_context` with the absolute project `directory`. +4. Pass the selected `providerID` and `modelID` when asking a question. -You now have access to **80 tools**, **10 resources**, and **6 prompts**. Start with these: +You can instead set both `OPENCODE_DEFAULT_PROVIDER` and `OPENCODE_DEFAULT_MODEL` in the MCP server's environment. -| Tool | What it does | -|---|---| -| `opencode_setup` | Check server health and provider config | -| `opencode_ask` | Ask OpenCode a question (one call, one answer) | -| `opencode_run` | Send a coding task and wait for it to finish | -| `opencode_fire` | Dispatch a task in the background | -| `opencode_check` | Check progress on a background task | -| `opencode_context` | Get project info, VCS status, agents | +## 4. Start Work -See the full [Tools Reference](tools.md) and [Examples](examples.md). +Use `opencode_ask` for a quick question. For longer coding work, use `opencode_fire` and save its returned job/session identifiers. Check progress with `opencode_check`, or wait with `opencode_wait`. -## Troubleshooting +If the state is `input_required`, inspect the pending permission or question and respond explicitly. A timeout means the observation period ended; continue monitoring instead of submitting the same task again. Once complete, use `opencode_review_changes` to inspect the result. -### "Connection refused" errors +See [examples](examples.md) for task recovery, structured output, and independent projects. -The configured OpenCode server is unavailable. Start it manually: - -```bash -opencode serve -``` +## Troubleshooting -If auto-start keeps failing, check that `opencode` is on your PATH: +### Connection refused -```bash -which opencode -``` +Start OpenCode on the configured port and check `OPENCODE_BASE_URL`. For opt-in auto-start, verify `opencode` is on the MCP process's PATH using `command -v opencode` on macOS/Linux or `Get-Command opencode` in PowerShell. -### "Unauthorized" errors - -The OpenCode server has auth enabled. Add credentials: - -```json -{ - "mcpServers": { - "opencode": { - "command": "npx", - "args": ["-y", "opencode-mcp"], - "env": { - "OPENCODE_SERVER_USERNAME": "myuser", - "OPENCODE_SERVER_PASSWORD": "mypass" - } - } - } -} -``` +### Unauthorized -### Tools not showing up +Configure matching `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD` values on the OpenCode server and MCP process. The username defaults to `opencode`. Do not place credentials in shared project configuration. -- Restart the client after editing the config -- Check that `npx opencode-mcp` runs without errors in a terminal -- Make sure your MCP client supports tools +### Tools missing -### Opt in to auto-start +Restart the MCP connection after editing settings. Check the client's MCP logs and verify Node.js 22 or newer is available to the client. If `OPENCODE_TOOL_PROFILE=essential`, specialist tools are intentionally omitted; select `full` when needed. -To allow MCP to start a local server when none is reachable: +### Work stopped after closing the client -```json -{ - "env": { - "OPENCODE_AUTO_SERVE": "true" - } -} -``` +Jobs persist metadata, but OpenCode must remain running to execute work. An auto-started child closes with MCP. Use an externally managed OpenCode server for work that must survive MCP disconnects. -## Next Steps +### Task never reports completion -- [Configuration](configuration.md): all env vars and client configs -- [Tools Reference](tools.md): all 80 tools -- [Examples](examples.md): real workflow examples -- [Prompts](prompts.md): 6 guided workflow templates +Check its current state and pending input. Use `opencode_job_list` to rediscover tracked work after reconnecting. A server/network error or `unknown` state is not a reason to submit the same prompt again without checking its existing session. diff --git a/docs/prompts.md b/docs/prompts.md index 412e0a6..79d94d9 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -1,99 +1,26 @@ # Prompts Reference -MCP Prompts are guided workflow templates that your client can offer as selectable actions. They structure multi-step interactions with OpenCode. +MCP prompts return workflow instructions for the client to follow. Requesting a prompt does not execute its suggested tools or approve an operation. The six existing prompt names and input schemas are unchanged. -## Available Prompts (6) +| Prompt | Arguments | Guidance | +|---|---|---| +| `opencode-code-review` | `sessionId` | Confirm project scope, inspect diffs, and provide evidence-based review feedback | +| `opencode-debug` | `issue`, optional `context` | Inspect setup, choose a model, investigate, then propose or implement an authorized fix | +| `opencode-project-setup` | none | Read project context and key files; use a read-only OpenCode request when low-level file tools are not advertised | +| `opencode-implement` | `description`, optional `requirements` | Dispatch implementation through run/fire, retain identifiers, resolve explicit input, review changes and tests | +| `opencode-best-practices` | none | Select tools/models, interpret async states, recover jobs, handle input, and understand scope and lifecycle | +| `opencode-session-summary` | `sessionId` | Check current state, inspect bounded history and changes, and summarize completed and remaining work | -### `opencode-code-review` +## Async Guidance -Review code changes in an OpenCode session. +The implementation and debugging prompts retain the returned `jobId`, `sessionId`, and `messageId`. They use `opencode_job_get`, `opencode_check`, or `opencode_wait` to follow existing work. A timeout does not instruct the client to submit a duplicate prompt. -| Argument | Type | Required | Description | -|---|---|---|---| -| `sessionId` | string | yes | Session ID to review | +When work reports `input_required`, the prompt asks the client to present the pending permission or question and forward the user's explicit response. It does not recommend bypassing the request with blanket permission approval. -**What it does:** -1. Fetches the diff with `opencode_review_changes` -2. Analyzes changes for correctness, style, performance, and security -3. Provides structured line-level feedback -4. Suggests improvements +## Project and Model Context ---- +Use the absolute directory on the OpenCode server for relevant tool calls. These prompt schemas do not contain a directory argument; the client must use the project directory established in the conversation. Do not assume that a previous tool call changed the server's default project. -### `opencode-debug` +Provider/model IDs come from discovery or both configured environment defaults. The essential profile supports the common workflow tools, while specialist file/API tools require the full profile. Prompts should only invoke capabilities actually advertised to the client. -Start a guided debugging session. - -| Argument | Type | Required | Description | -|---|---|---|---| -| `issue` | string | yes | Description of the bug | -| `context` | string | no | Additional context (file paths, error messages) | - -**What it does:** -1. Gets project context with `opencode_context` -2. Searches for relevant files -3. Reads and analyzes source code -4. Identifies root cause and suggests a fix - ---- - -### `opencode-project-setup` - -Get oriented in a new project. - -*No arguments.* - -**What it does:** -1. Gets project info, VCS status, and available agents -2. Lists the project structure -3. Reads key files (README, package.json, configs, entry points) -4. Provides a summary: what the project does, tech stack, structure, how to build/run - ---- - -### `opencode-implement` - -Have OpenCode implement a feature or make changes. - -| Argument | Type | Required | Description | -|---|---|---|---| -| `description` | string | yes | What to implement | -| `requirements` | string | no | Specific requirements or constraints | - -**What it does:** -1. Gets project context -2. Sends the implementation request to OpenCode's build agent -3. Reviews the changes made -4. Reports what was implemented and any follow-up items - ---- - -### `opencode-best-practices` - -Get guidance on using opencode-mcp effectively. - -*No arguments.* - -**What it does:** -Provides structured advice on: -- Initial setup and provider configuration -- Tool selection (which tools to use for which tasks) -- Session management and monitoring patterns -- Common pitfalls and how to avoid them -- Recommended workflows for common scenarios - ---- - -### `opencode-session-summary` - -Summarize what happened in an OpenCode session. - -| Argument | Type | Required | Description | -|---|---|---|---| -| `sessionId` | string | yes | Session ID to summarize | - -**What it does:** -1. Gets session metadata -2. Reads the full conversation history -3. Reviews file changes -4. Provides a summary: what was discussed, actions taken, files modified, remaining work +See [examples](examples.md) for concrete tool calls and [configuration](configuration.md) for profiles and lifecycle behavior. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..5ec140f --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,65 @@ +# Release and live verification + +## Local smoke checks + +Build the package, start a local OpenCode server, then run: + +```bash +npm run build +node scripts/mcp-smoke-test.mjs +``` + +The runner creates a temporary Git project and a session owned by that test run. It checks health, project scope, file/search responses, session reads, and monitoring. It deletes its own session in `finally`, closes the MCP connection, and removes its temporary directory and isolated job store. It never selects an existing session for mutation, shares a session, or changes global configuration. Git must be installed. + +No model calls run by default. Tools outside these fixture checks appear as **SKIP**, with a reason; a successful smoke run does not claim coverage of those capabilities. Any unexpected tool error, failed fixture assertion, or failed session cleanup makes the command exit nonzero. Response bodies and server diagnostics are not printed because they can contain credentials or private configuration. + +The runner uses a local server because the disposable project must be on the same filesystem as OpenCode. It rejects remote URLs instead of interpreting a local temporary path on another machine. Authentication uses the normal `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD` environment variables, forwarded to the MCP child; keep secrets out of command arguments. `OPENCODE_AUTO_SERVE` is always disabled for this test. + +### Optional inference checks + +To verify async dispatch and completion with a provider and model you have chosen: + +```bash +node scripts/mcp-smoke-test.mjs --inference --provider YOUR_PROVIDER --model YOUR_MODEL +``` + +Alternatively, set all three environment variables: `OPENCODE_TEST_INFERENCE=true`, `OPENCODE_TEST_PROVIDER`, and `OPENCODE_TEST_MODEL`. Supplying a model alone does not enable inference. These checks can incur provider charges. + +The runner sends a text-only request through `opencode_fire`, checks progress, waits for completion, then exercises `opencode_run`. It uses the same owned session, runs prompts sequentially, and aborts owned inference before session deletion. A timeout fails verification and prevents a second prompt from being queued. The temporary project's permission policy denies model tool use. + +## Release checklist + +1. **Choose a version that has never been published.** Check npm's version list, repository tags, GitHub releases, and prior release notes: + ```bash + npm view opencode-mcp versions --json + git tag --list 'v*' + ``` + Absence from the current npm version list is not proof a version is reusable: npm can permanently reserve previously published versions even after removal. For this package, **2.0.0 is reserved**; 2.0.1 was the compatibility release. Never retry publication under a known reserved version. + +2. **Prepare the release metadata.** Update the package version and lockfile together, changelog, compatibility notes, and any generated tool reference. Check the reported MCP server version matches the package. Document changes to defaults, environment variables, runtime requirements, tool inputs, and result shapes. Review the final diff and ensure no credentials or local tooling caches are included. + +3. **Run the release checks from a clean installation.** + ```bash + npm ci + npm test + npm run docs:check + npm run test:coverage + npm audit + git diff --check + ``` + Confirm the required CI matrix passed on the exact release commit. Record the tested OpenCode version. The default fixture smoke checks should pass; explicitly select a provider/model when validating inference. Record skipped live capabilities accurately. + +4. **Inspect and test the actual archive.** + ```bash + npm pack --dry-run --json + npm pack --json + ``` + Inspect the file list and package metadata. Install the produced archive in a fresh temporary directory, using production dependencies only, then exercise its executable through an MCP client: initialization, tool discovery, a fixture-backed health call, and clean stdio shutdown. Repeat on the supported OS/runtime matrix where practical. This catches missing runtime dependencies or generated files that source-tree tests cannot detect. Record the archive's `integrity` value from the pack result. + +5. **Publish the tested archive after release authorization.** Verify the registry account with `npm whoami`, publish that exact `.tgz`, and complete any registry-required authentication. Avoid rebuilding between archive verification and publication. If npm rejects a reserved version, select a new version and repeat metadata, checks, packing, and archive validation before retrying. + +6. **Verify the registry artifact.** + ```bash + npm view opencode-mcp@VERSION version dist.integrity dist-tags --json + ``` + Compare `dist.integrity` with the tested archive's integrity and confirm `latest` points to the intended version. Install the exact published version in another clean directory and rerun the package smoke checks. Create the matching Git tag and GitHub release with migration notes and validation evidence. Do not describe a release as published until npm confirms it and these post-publication checks pass. diff --git a/docs/resources.md b/docs/resources.md index 2f1a212..84a1299 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -1,25 +1,56 @@ # Resources Reference -MCP Resources are browseable data endpoints that clients can read without calling tools. All return `application/json`. +Resources expose project data through MCP reads. They are independent of tool calls and return valid JSON, including when large data must be shortened. This server does **not** implement resource subscriptions or change notifications; read a resource again to refresh it. -## Available Resources (10) +## Static Resources -| URI | Description | +These URIs read the OpenCode server's default project. Reading a project-scoped tool with `directory` does not change their scope. + +| URI | Data | +|---|---| +| `opencode://project/current` | Current project | +| `opencode://config` | Configuration with sensitive fields redacted | +| `opencode://providers` | Providers and models with sensitive fields redacted | +| `opencode://agents` | Available agents | +| `opencode://commands` | Available commands | +| `opencode://health` | Server health and version | +| `opencode://vcs` | Version control information returned by OpenCode | +| `opencode://sessions` | Sessions in the default project | +| `opencode://mcp-servers` | Configured MCP server status | +| `opencode://file-status` | Version control file status | + +## Project and Session Templates + +Use these templates to select a different project without changing server defaults: + +| URI template | Data | |---|---| -| `opencode://project/current` | Current active project (name, path, config) | -| `opencode://config` | Full OpenCode configuration | -| `opencode://providers` | All providers, models, and connection status | -| `opencode://agents` | Available agents with names, modes, descriptions | -| `opencode://commands` | All slash commands (built-in and custom) | -| `opencode://health` | Server health and version (`{ "healthy": true, "version": "x.y.z" }`) | -| `opencode://vcs` | Git info: branch, remote, commit, dirty status | -| `opencode://sessions` | All sessions with IDs, titles, dates, parent relationships | -| `opencode://mcp-servers` | Status of all configured MCP servers in OpenCode | -| `opencode://file-status` | VCS status of tracked files (modified, added, deleted) | - -## How Resources Differ from Tools - -- **Resources** are read-only data endpoints. Clients can browse and subscribe to them. -- **Tools** are actions that can read data, create sessions, modify files, etc. - -For example, `opencode://providers` gives you provider data passively. To actually set an API key, you'd use the `opencode_auth_set` tool. +| `opencode://projects/{directory}/current` | Project metadata | +| `opencode://projects/{directory}/sessions` | Project sessions | +| `opencode://projects/{directory}/sessions/{sessionId}` | One session | +| `opencode://projects/{directory}/sessions/{sessionId}/messages` | Session messages | + +Encode the entire absolute server directory as one URI component with `encodeURIComponent`. The server decodes it once and validates it using the same absolute-path rules as project tools. For example: + +```javascript +const directory = encodeURIComponent("/home/user/my-project"); +const uri = `opencode://projects/${directory}/sessions`; +// opencode://projects/%2Fhome%2Fuser%2Fmy-project/sessions +``` + +Windows drive and UNC paths work the same way. Encode `%` characters too; do not repeatedly decode the path or substitute an MCP-host path for a remote server path. + +## Large Responses + +When serialized data exceeds the response budget, the JSON content is an envelope: + +```json +{ + "truncated": true, + "originalLength": 70000, + "omittedCharacters": 20000, + "preview": "A prefix of the serialized data..." +} +``` + +The numbers above illustrate the fields. `preview` is a string prefix, not a complete document to parse as JSON. Use narrower project/session resources or tool limits to retrieve manageable data. Reading resources never grants permission to mutate their underlying sessions or project. diff --git a/docs/tools.md b/docs/tools.md index 0400955..9798d14 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -1,333 +1,6887 @@ # Tools Reference -Complete reference for all 80 tools provided by opencode-mcp. + -Every tool accepts an optional `directory` parameter (absolute path) to target a specific project. All tools include [MCP tool annotations](https://modelcontextprotocol.io/docs/concepts/tools#tool-annotations) (`readOnlyHint`, `destructiveHint`) so clients can make informed decisions about tool safety. +The full profile exposes **87 tools**; the essential profile exposes **25 tools**. This reference is generated from the registered input/output schemas and behavior annotations. -## Table of Contents +Set `OPENCODE_TOOL_PROFILE=essential` for the smaller catalog; `full` is the default. Profile availability is listed for each tool. Profiles control discovery, not OpenCode permissions. -- [Workflow Tools (13)](#workflow-tools) — start here +## Input and Output Contracts + +When present, `directory` means an absolute path on the OpenCode server. Global authentication tools omit it; local project initialization uses `path`. See [configuration](configuration.md) for scope and [examples](examples.md) for workflows. + +Results retain readable MCP `content` and provide `structuredContent`. Structured outputs include `text` and `isError`; generic JSON lives in `data`, while async tools provide identifiers and state fields. Tool errors also set the MCP result's `isError`. Inspect state rather than parsing text. An observation timeout can return a running state without an error or cancellation. + +Async states are `accepted`, `running`, `input_required`, `completed`, `failed`, `cancelled`, and `unknown`. Native MCP task execution is negotiated separately from ordinary tool calls. See [architecture](architecture.md). + +Annotations are behavior hints, not an authorization boundary. Nested structures and constraints are included in each tool's expandable JSON schema. + +## Categories + +- [Workflow Tools (13)](#workflow-tools) +- [Job Tools (4)](#job-tools) +- [Question Tools (3)](#question-tools) - [Session Tools (20)](#session-tools) - [Message Tools (6)](#message-tools) - [File & Search Tools (6)](#file--search-tools) - [Config Tools (3)](#config-tools) - [Provider & Auth Tools (6)](#provider--auth-tools) - [TUI Control Tools (9)](#tui-control-tools) -- [System & Monitoring Tools (12)](#system--monitoring-tools) +- [Project Tools (3)](#project-tools) - [Event Tools (1)](#event-tools) -- [Project Tools (2)](#project-tools) - [Global Tools (1)](#global-tools) - ---- +- [System & Monitoring Tools (12)](#system--monitoring-tools) ## Workflow Tools -High-level tools that combine multiple API calls into single, LLM-friendly operations. **Start here** — these cover most use cases. +### `opencode_ask` + +Ask OpenCode a question in one step. Creates a new session, sends your prompt, and returns the AI response. This is the easiest way to interact with OpenCode. -### `opencode_setup` +**Profiles:** full, essential. -Check server health, provider config, and project status. **Use this as your first call.** +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `directory` | string | no | Target project directory | +| `prompt` | string | yes | The question or instruction to send | +| `title` | string | no | Optional title for the session | +| `providerID` | string | no | Provider ID (e.g. 'anthropic') | +| `modelID` | string | no | Model ID (e.g. 'claude-3-5-sonnet-20241022') | +| `variant` | string | no | Model variant (e.g. 'fast', 'smart') | +| `agent` | string | no | Agent to use (e.g. 'build', 'plan') | +| `system` | string | no | Optional system prompt override | +| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | ---- +#### Structured output -### `opencode_ask` +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas -One-shot interaction — creates a session, sends a prompt, returns the AI response. +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "The question or instruction to send" + }, + "title": { + "description": "Optional title for the session", + "type": "string" + }, + "providerID": { + "description": "Provider ID (e.g. 'anthropic')", + "type": "string" + }, + "modelID": { + "description": "Model ID (e.g. 'claude-3-5-sonnet-20241022')", + "type": "string" + }, + "variant": { + "description": "Model variant (e.g. 'fast', 'smart')", + "type": "string" + }, + "agent": { + "description": "Agent to use (e.g. 'build', 'plan')", + "type": "string" + }, + "system": { + "description": "Optional system prompt override", + "type": "string" + }, + "format": { + "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "json_schema" + }, + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "retryCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "schema" + ], + "additionalProperties": false + } + ] + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "prompt" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_check` + +Get current task status, pending input, todos and file counts. Uses session summary metadata instead of fetching patches. Pass jobId for exact durable turn correlation; detailed includes response text. + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `prompt` | string | yes | The question or instruction | -| `title` | string | no | Session title | -| `providerID` | string | no | Provider (e.g. `"anthropic"`) | -| `modelID` | string | no | Model (e.g. `"claude-opus-4-6"`) | -| `agent` | string | no | Agent (e.g. `"build"`, `"plan"`) | -| `system` | string | no | System prompt override | +| `sessionId` | string | no | Session ID (required unless jobId is supplied) | +| `jobId` | string | no | Durable job ID | +| `messageId` | string | no | Submitted user message ID to correlate | +| `detailed` | boolean | no | Include response text (default false) | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | ---- +#### Structured output -### `opencode_reply` +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `sessionId` | string | no | | +| `jobId` | string | no | | +| `messageId` | string | no | | +| `directory` | string | no | | +| `status` | "accepted" \| "running" \| "input_required" \| "completed" \| "failed" \| "cancelled" \| "unknown" | yes | | +| `result` | unknown | no | | +| `error` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "sessionId": { + "description": "Session ID (required unless jobId is supplied)", + "type": "string" + }, + "jobId": { + "description": "Durable job ID", + "type": "string" + }, + "messageId": { + "description": "Submitted user message ID to correlate", + "type": "string" + }, + "detailed": { + "description": "Include response text (default false)", + "type": "boolean" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "sessionId": { + "type": "string" + }, + "jobId": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "accepted", + "running", + "input_required", + "completed", + "failed", + "cancelled", + "unknown" + ] + }, + "result": {}, + "error": {} + }, + "required": [ + "text", + "isError", + "status" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_context` -Follow-up message in an existing session. +Get full project context in one call: current project, path, VCS info, config, and available agents. Useful to understand the current state before starting work. + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `sessionId` | string | yes | Session ID | -| `prompt` | string | yes | The follow-up message | -| `providerID` | string | no | Provider ID | -| `modelID` | string | no | Model ID | -| `agent` | string | no | Agent to use | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | ---- +#### Structured output -### `opencode_run` +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_conversation` + +Get the full conversation history of a session, formatted for easy reading. Shows all messages with their roles and content. -**Send a task and wait for completion.** Combines session creation, async prompt dispatch, and polling into one call. Best for tasks you want to wait on (up to 10 minutes by default). +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `prompt` | string | yes | The task or instruction | -| `sessionId` | string | no | Existing session to continue (omit to create new) | -| `title` | string | no | Session title (new sessions only) | -| `providerID` | string | no | Provider ID | -| `modelID` | string | no | Model ID | -| `agent` | string | no | Agent to use | -| `maxDurationSeconds` | number | no | Max wait time (default: 600 = 10 min) | +| `sessionId` | string | yes | Session ID | +| `limit` | number | no | Max messages to return (default: all) | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas ---- +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Session ID" + }, + "limit": { + "description": "Max messages to return (default: all)", + "type": "number" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
### `opencode_fire` -**Fire-and-forget** — dispatch a task and return immediately. OpenCode works autonomously in the background. Use `opencode_check` to monitor progress. +Dispatch a durable background task and return its job, session and message IDs immediately. Use opencode_check or opencode_wait to observe; job_cancel stops the task. + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `prompt` | string | yes | Task or instruction to send to OpenCode; minimum length: 1 | +| `sessionId` | string | no | Existing session to continue; omit to create one | +| `title` | string | no | Title for a new session | +| `providerID` | string | no | Provider ID from provider discovery | +| `modelID` | string | no | Model ID from provider discovery | +| `variant` | string | no | Model variant | +| `agent` | string | no | OpenCode agent name | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | +| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `sessionId` | string | no | | +| `jobId` | string | no | | +| `messageId` | string | no | | +| `directory` | string | no | | +| `status` | "accepted" \| "running" \| "input_required" \| "completed" \| "failed" \| "cancelled" \| "unknown" | yes | | +| `result` | unknown | no | | +| `error` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "prompt": { + "type": "string", + "minLength": 1, + "description": "Task or instruction to send to OpenCode" + }, + "sessionId": { + "description": "Existing session to continue; omit to create one", + "type": "string" + }, + "title": { + "description": "Title for a new session", + "type": "string" + }, + "providerID": { + "description": "Provider ID from provider discovery", + "type": "string" + }, + "modelID": { + "description": "Model ID from provider discovery", + "type": "string" + }, + "variant": { + "description": "Model variant", + "type": "string" + }, + "agent": { + "description": "OpenCode agent name", + "type": "string" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + }, + "format": { + "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "json_schema" + }, + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "retryCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "schema" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "prompt" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "sessionId": { + "type": "string" + }, + "jobId": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "accepted", + "running", + "input_required", + "completed", + "failed", + "cancelled", + "unknown" + ] + }, + "result": {}, + "error": {} + }, + "required": [ + "text", + "isError", + "status" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_provider_test` + +Quick-test whether a provider is working. Creates a temporary session, sends a trivial prompt, checks the response, and cleans up. Great for debugging auth issues. + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `providerId` | string | yes | Provider ID to test (e.g. 'anthropic', 'openrouter') | +| `modelID` | string | no | Specific model ID to test. If omitted, discovers the provider default or first available model. | +| `variant` | string | no | Model variant | +| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "providerId": { + "type": "string", + "description": "Provider ID to test (e.g. 'anthropic', 'openrouter')" + }, + "modelID": { + "description": "Specific model ID to test. If omitted, discovers the provider default or first available model.", + "type": "string" + }, + "variant": { + "description": "Model variant", + "type": "string" + }, + "format": { + "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "json_schema" + }, + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "retryCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "schema" + ], + "additionalProperties": false + } + ] + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "providerId" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_reply` + +Send a follow-up message to an existing session. Use this to continue a conversation started with opencode_ask or opencode_session_create. + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `prompt` | string | yes | The task or instruction | -| `sessionId` | string | no | Existing session to continue (omit to create new) | -| `title` | string | no | Session title (new sessions only) | +| `sessionId` | string | yes | Session ID to reply in | +| `prompt` | string | yes | The follow-up message | | `providerID` | string | no | Provider ID | | `modelID` | string | no | Model ID | +| `variant` | string | no | Model variant | | `agent` | string | no | Agent to use | +| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output -Returns: session ID + monitoring instructions. +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | ---- +
+Complete input and output JSON schemas -### `opencode_check` +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Session ID to reply in" + }, + "prompt": { + "type": "string", + "description": "The follow-up message" + }, + "providerID": { + "description": "Provider ID", + "type": "string" + }, + "modelID": { + "description": "Model ID", + "type": "string" + }, + "variant": { + "description": "Model variant", + "type": "string" + }, + "agent": { + "description": "Agent to use", + "type": "string" + }, + "format": { + "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "json_schema" + }, + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "retryCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "schema" + ], + "additionalProperties": false + } + ] + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "sessionId", + "prompt" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` -**Compact progress report** for a running session. Returns status, todo progress (completed/total + current task), and file change count. Much cheaper than `opencode_conversation` or `opencode_wait`. +
+ +### `opencode_review_changes` + +Get a formatted summary of all file changes made in a session. Shows diffs in a readable format. + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `sessionId` | string | yes | Session ID to check | -| `detailed` | boolean | no | Include last message text (default: false) | +| `sessionId` | string | yes | Session ID | +| `messageID` | string | no | Specific message ID to get diff for | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | ---- +#### Structured output -### `opencode_conversation` +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Session ID" + }, + "messageID": { + "description": "Specific message ID to get diff for", + "type": "string" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_run` + +Send a task and wait for its correlated response. Returns a durable job ID; an observation timeout leaves the remote job running and can be resumed with opencode_wait. + +**Profiles:** full, essential. -Full conversation history of a session, formatted for reading. +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `sessionId` | string | yes | Session ID | -| `limit` | number | no | Max messages to return | +| `prompt` | string | yes | Task or instruction to send to OpenCode; minimum length: 1 | +| `sessionId` | string | no | Existing session to continue; omit to create one | +| `title` | string | no | Title for a new session | +| `providerID` | string | no | Provider ID from provider discovery | +| `modelID` | string | no | Model ID from provider discovery | +| `variant` | string | no | Model variant | +| `agent` | string | no | OpenCode agent name | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | +| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | +| `maxDurationSeconds` | number | no | Maximum observation duration in seconds (default 600); timeout does not abort the job; maximum: 3600 | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `sessionId` | string | no | | +| `jobId` | string | no | | +| `messageId` | string | no | | +| `directory` | string | no | | +| `status` | "accepted" \| "running" \| "input_required" \| "completed" \| "failed" \| "cancelled" \| "unknown" | yes | | +| `result` | unknown | no | | +| `error` | unknown | no | | + +
+Complete input and output JSON schemas ---- +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "prompt": { + "type": "string", + "minLength": 1, + "description": "Task or instruction to send to OpenCode" + }, + "sessionId": { + "description": "Existing session to continue; omit to create one", + "type": "string" + }, + "title": { + "description": "Title for a new session", + "type": "string" + }, + "providerID": { + "description": "Provider ID from provider discovery", + "type": "string" + }, + "modelID": { + "description": "Model ID from provider discovery", + "type": "string" + }, + "variant": { + "description": "Model variant", + "type": "string" + }, + "agent": { + "description": "OpenCode agent name", + "type": "string" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + }, + "format": { + "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "json_schema" + }, + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "retryCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "schema" + ], + "additionalProperties": false + } + ] + }, + "maxDurationSeconds": { + "description": "Maximum observation duration in seconds (default 600); timeout does not abort the job", + "type": "number", + "exclusiveMinimum": 0, + "maximum": 3600 + } + }, + "required": [ + "prompt" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "sessionId": { + "type": "string" + }, + "jobId": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "accepted", + "running", + "input_required", + "completed", + "failed", + "cancelled", + "unknown" + ] + }, + "result": {}, + "error": {} + }, + "required": [ + "text", + "isError", + "status" + ], + "additionalProperties": {} + } +} +``` + +
### `opencode_sessions_overview` -Quick overview of all sessions with titles, IDs, and status. +Get a quick overview of all sessions with their titles and status. Useful to find which session to continue working in. -*No parameters.* +**Profiles:** full. ---- +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. -### `opencode_context` +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` -Full project context in one call: project info, path, VCS, config, agents. +
-*No parameters (accepts optional `directory`).* +### `opencode_setup` + +Check OpenCode status, provider configuration, and optionally initialize a project directory. Use this as the first step when starting work — it tells you what is ready and what still needs configuration. + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_status` ---- +Get a quick status dashboard: server health, provider count, session count, and VCS info. Lighter than opencode_setup — good for at-a-glance checks. + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
### `opencode_wait` -Poll a session until it finishes. Use after `opencode_message_send_async`. On timeout, returns actionable suggestions. +Wait for a session or durable job. Returns completed, failed, input_required, or a resumable timeout. Cancelling this wait stops observation; use job_cancel or session_abort to stop OpenCode. + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `sessionId` | string | yes | Session ID | -| `timeoutSeconds` | number | no | Max wait (default: 120) | -| `pollIntervalMs` | number | no | Poll interval in ms (default: 2000) | +| `sessionId` | string | no | Session ID (required unless jobId is supplied) | +| `jobId` | string | no | Durable job ID returned by opencode_fire or opencode_run | +| `messageId` | string | no | Submitted user message ID to correlate the exact response | +| `timeoutSeconds` | number | no | Maximum seconds to wait (default 120); timeout is resumable; minimum: 0; maximum: 86400 | +| `pollIntervalMs` | integer | no | Polling interval in milliseconds (default 2000); minimum: 10; maximum: 60000 | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | -> **Prefer `opencode_run`** for new tasks — it handles session creation + async send + polling in one call. +#### Structured output ---- +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `sessionId` | string | no | | +| `jobId` | string | no | | +| `messageId` | string | no | | +| `directory` | string | no | | +| `status` | "accepted" \| "running" \| "input_required" \| "completed" \| "failed" \| "cancelled" \| "unknown" | yes | | +| `result` | unknown | no | | +| `error` | unknown | no | | -### `opencode_review_changes` +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "sessionId": { + "description": "Session ID (required unless jobId is supplied)", + "type": "string" + }, + "jobId": { + "description": "Durable job ID returned by opencode_fire or opencode_run", + "type": "string" + }, + "messageId": { + "description": "Submitted user message ID to correlate the exact response", + "type": "string" + }, + "timeoutSeconds": { + "description": "Maximum seconds to wait (default 120); timeout is resumable", + "type": "number", + "minimum": 0, + "maximum": 86400 + }, + "pollIntervalMs": { + "description": "Polling interval in milliseconds (default 2000)", + "type": "integer", + "minimum": 10, + "maximum": 60000 + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "sessionId": { + "type": "string" + }, + "jobId": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "accepted", + "running", + "input_required", + "completed", + "failed", + "cancelled", + "unknown" + ] + }, + "result": {}, + "error": {} + }, + "required": [ + "text", + "isError", + "status" + ], + "additionalProperties": {} + } +} +``` + +
+ +## Job Tools + +### `opencode_job_cancel` + +Explicitly abort the OpenCode session owned by a job. This also stops other work in that session. + +**Profiles:** full, essential. -Formatted diff summary of file changes in a session. +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `sessionId` | string | yes | Session ID | -| `messageID` | string | no | Specific message ID | +| `jobId` | string | yes | minimum length: 1 | ---- +#### Structured output -### `opencode_provider_test` +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `sessionId` | string | no | | +| `jobId` | string | no | | +| `messageId` | string | no | | +| `directory` | string | no | | +| `status` | "accepted" \| "running" \| "input_required" \| "completed" \| "failed" \| "cancelled" \| "unknown" | yes | | +| `result` | unknown | no | | +| `error` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "jobId" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "sessionId": { + "type": "string" + }, + "jobId": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "accepted", + "running", + "input_required", + "completed", + "failed", + "cancelled", + "unknown" + ] + }, + "result": {}, + "error": {} + }, + "required": [ + "text", + "isError", + "status" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_job_get` -Quick-test whether a provider works. Creates a temporary session, sends a trivial prompt, checks the response, cleans up. +Observe a durable job by ID, including pending inputs and final results. + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `providerId` | string | yes | Provider ID to test | -| `modelID` | string | no | Model ID (defaults to provider default) | +| `jobId` | string | yes | minimum length: 1 | ---- +#### Structured output -### `opencode_status` +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `sessionId` | string | no | | +| `jobId` | string | no | | +| `messageId` | string | no | | +| `directory` | string | no | | +| `status` | "accepted" \| "running" \| "input_required" \| "completed" \| "failed" \| "cancelled" \| "unknown" | yes | | +| `result` | unknown | no | | +| `error` | unknown | no | | -At-a-glance dashboard: health, provider count, session count, VCS info. +
+Complete input and output JSON schemas -*No parameters (accepts optional `directory`).* +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "jobId" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "sessionId": { + "type": "string" + }, + "jobId": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "accepted", + "running", + "input_required", + "completed", + "failed", + "cancelled", + "unknown" + ] + }, + "result": {}, + "error": {} + }, + "required": [ + "text", + "isError", + "status" + ], + "additionalProperties": {} + } +} +``` ---- +
-## Session Tools +### `opencode_job_input` -Full lifecycle management of OpenCode sessions (20 tools). - -| Tool | Key Parameters | Description | -|---|---|---| -| `opencode_session_list` | — | List all sessions | -| `opencode_session_create` | `title?`, `parentID?` | Create a new session | -| `opencode_session_get` | `id` | Get session details | -| `opencode_session_delete` | `id` | Delete a session | -| `opencode_session_update` | `id`, `title?` | Update session properties | -| `opencode_session_search` | `query` | Search sessions by keyword | -| `opencode_session_children` | `id` | Get child sessions | -| `opencode_session_status` | — | Status for all sessions | -| `opencode_session_todo` | `id` | Get the todo list | -| `opencode_session_init` | `id`, `messageID`, `providerID`, `modelID` | Create AGENTS.md (slow) | -| `opencode_session_abort` | `id` | Abort a running session | -| `opencode_session_fork` | `id`, `messageID?` | Fork a session | -| `opencode_session_share` | `id` | Share publicly | -| `opencode_session_unshare` | `id` | Unshare | -| `opencode_session_diff` | `id`, `messageID?` | Get raw diff | -| `opencode_session_summarize` | `id`, `providerID`, `modelID` | AI-summarize (slow) | -| `opencode_session_revert` | `id`, `messageID`, `partID?` | Revert a message | -| `opencode_session_unrevert` | `id` | Restore reverted messages | -| `opencode_session_permission` | `id`, `permissionID`, `reply` | Respond to a permission request (`once`, `always`, `reject`) | -| `opencode_permission_list` | — | List all pending permission requests across sessions | - ---- +Respond to a job's pending questions or permissions. Omit responses to request MCP forms when supported, or receive manual response instructions. Never approves automatically. -## Message Tools +**Profiles:** full, essential. -Send prompts and execute commands (6 tools). +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. -| Tool | Key Parameters | Description | -|---|---|---| -| `opencode_message_list` | `sessionId`, `limit?` | List messages in a session | -| `opencode_message_get` | `sessionId`, `messageId` | Get a specific message | -| `opencode_message_send` | `sessionId`, `text`, `providerID?`, `modelID?` | Send prompt (sync, waits for response) | -| `opencode_message_send_async` | `sessionId`, `text`, `providerID?`, `modelID?` | Send prompt (async, returns immediately) | -| `opencode_command_execute` | `sessionId`, `command`, `arguments?` | Execute a slash command | -| `opencode_shell_execute` | `sessionId`, `command`, `agent` | Run a shell command | +#### Parameters ---- +| Parameter | Type | Required | Description | +|---|---|---|---| +| `jobId` | string | yes | minimum length: 1 | +| `responses` | array | no | | -## File & Search Tools +#### Structured output -Search and read project files (6 tools). +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | -| Tool | Key Parameters | Description | -|---|---|---| -| `opencode_find_text` | `pattern` | Search text/regex across the project | -| `opencode_find_file` | `query`, `type?`, `limit?` | Find files by name (fuzzy) | -| `opencode_find_symbol` | `query` | Find workspace symbols (functions, classes, etc.) | -| `opencode_file_list` | `path?` | List directory contents | -| `opencode_file_read` | `path` | Read a file | -| `opencode_file_status` | — | VCS status for all tracked files | +
+Complete input and output JSON schemas ---- +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1 + }, + "responses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": [ + "question", + "permission" + ] + }, + "answers": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "reply": { + "type": "string", + "enum": [ + "once", + "always", + "reject" + ] + }, + "reject": { + "type": "boolean" + } + }, + "required": [ + "id", + "kind" + ], + "additionalProperties": false + } + } + }, + "required": [ + "jobId" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` -## Config Tools +
-Read and update OpenCode configuration (3 tools). +### `opencode_job_list` -| Tool | Key Parameters | Description | -|---|---|---| -| `opencode_config_get` | — | Get current config | -| `opencode_config_update` | `config` | Update config (partial merge) | -| `opencode_config_providers` | — | List configured providers and default models | +List locally retained jobs in this OpenCode server and credential scope. ---- +**Profiles:** full, essential. -## Provider & Auth Tools +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. -Manage LLM providers and authentication (6 tools). +#### Parameters -| Tool | Key Parameters | Description | -|---|---|---| -| `opencode_provider_list` | — | List providers with connection status | -| `opencode_provider_models` | `providerId`, `limit?` | List models for a provider | -| `opencode_provider_auth_methods` | — | Get available auth methods | -| `opencode_provider_oauth_authorize` | `providerId` | Start OAuth flow | -| `opencode_provider_oauth_callback` | `providerId`, `callbackData` | Handle OAuth callback | -| `opencode_auth_set` | `providerId`, `type`, `key` | Set API key | +| Parameter | Type | Required | Description | +|---|---|---|---| +| `limit` | integer | no | maximum: 200 | ---- +#### Structured output -## TUI Control Tools +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | -Remote-control the OpenCode terminal UI (9 tools). +
+Complete input and output JSON schemas -| Tool | Key Parameters | Description | -|---|---|---| -| `opencode_tui_append_prompt` | `text` | Append text to prompt input | -| `opencode_tui_submit_prompt` | — | Submit the current prompt | -| `opencode_tui_clear_prompt` | — | Clear the prompt | -| `opencode_tui_execute_command` | `command` | Execute a slash command | -| `opencode_tui_show_toast` | `message`, `title?`, `variant?` | Show toast notification | -| `opencode_tui_open_help` | — | Open help dialog | -| `opencode_tui_open_sessions` | — | Open session selector | -| `opencode_tui_open_models` | — | Open model selector | -| `opencode_tui_open_themes` | — | Open theme selector | +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "limit": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 200 + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` ---- +
-## System & Monitoring Tools +## Question Tools -Server health, VCS, LSP, and infrastructure tools (12 tools). - -| Tool | Key Parameters | Description | -|---|---|---| -| `opencode_health` | — | Server health and version | -| `opencode_path_get` | — | Current working path | -| `opencode_vcs_info` | — | Git branch, remote, status | -| `opencode_instance_dispose` | — | Shut down the instance (destructive) | -| `opencode_agent_list` | — | List agents with descriptions | -| `opencode_command_list` | — | List all slash commands | -| `opencode_lsp_status` | — | LSP server status | -| `opencode_formatter_status` | — | Formatter status | -| `opencode_mcp_status` | — | MCP server status | -| `opencode_mcp_add` | `name`, `config` | Add MCP server dynamically | -| `opencode_tool_ids` | — | List tool IDs (experimental) | -| `opencode_tool_list` | `provider`, `model` | List tools with JSON schemas | - ---- +### `opencode_question_list` -## Event Tools +List pending OpenCode questions, optionally filtered to a session. -### `opencode_events_poll` +**Profiles:** full, essential. -Poll for real-time SSE events from the server. +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `durationMs` | number | no | Collection duration (default: 3000, max: 30000) | -| `maxEvents` | number | no | Max events (default: 50) | +| `sessionId` | string | no | | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | ---- +#### Structured output -## Project Tools +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | -| Tool | Key Parameters | Description | -|---|---|---| -| `opencode_project_list` | — | List all known projects | -| `opencode_project_init` | `path` (absolute) | Initialize or open a project directory for an independent OpenCode session. Validates the path (rejects NUL bytes, `\r`/`\n`, and system roots like `/`, `/etc`, `/usr`, `/bin`, `/sbin`, `/sys`, `/proc`, `/dev`), creates the directory if missing, follows symlinks via `realpath` and re-checks the deny-list against the canonical path, then pings the OpenCode server so it registers as a project. Returns the canonical absolute path to use in subsequent `directory` parameters. | -| `opencode_project_current` | — | Get the current active project | +
+Complete input and output JSON schemas ---- +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` -## Global Tools +
+ +### `opencode_question_reject` + +Reject a pending OpenCode question explicitly. + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `requestId` | string | yes | minimum length: 1 | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "requestId": { + "type": "string", + "minLength": 1 + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "requestId" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_question_reply` + +Answer an OpenCode question request. Supply one array of selected labels or free text per question. + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `requestId` | string | yes | minimum length: 1 | +| `answers` | array> | yes | | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "requestId": { + "type": "string", + "minLength": 1 + }, + "answers": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "requestId", + "answers" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +## Session Tools + +### `opencode_permission_list` + +List all pending permission requests across all sessions. When a session is blocked waiting for approval (e.g. to run a shell command or access a file outside the project), it appears here. Respond with `opencode_session_permission`. + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_abort` + +Abort a running session + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `id` | string | yes | Session ID to abort | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Session ID to abort" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_children` + +Get child sessions of a session + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `id` | string | yes | Parent session ID | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Parent session ID" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_create` + +Create a new session. Optionally provide a parentID to create a child session, and a title. + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `parentID` | string | no | Parent session ID | +| `title` | string | no | Session title | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `sessionId` | string | yes | | +| `session` | unknown | yes | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "parentID": { + "description": "Parent session ID", + "type": "string" + }, + "title": { + "description": "Session title", + "type": "string" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "sessionId": { + "type": "string" + }, + "session": {} + }, + "required": [ + "text", + "isError", + "sessionId", + "session" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_delete` + +Delete a session and all its data + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `id` | string | yes | Session ID to delete | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Session ID to delete" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_diff` + +Get the diff for a session, optionally for a specific message + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `id` | string | yes | Session ID | +| `messageID` | string | no | Message ID (optional) | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Session ID" + }, + "messageID": { + "description": "Message ID (optional)", + "type": "string" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_fork` + +Fork an existing session, optionally at a specific message + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `id` | string | yes | Session ID to fork | +| `messageID` | string | no | Message ID to fork at (optional) | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Session ID to fork" + }, + "messageID": { + "description": "Message ID to fork at (optional)", + "type": "string" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_get` + +Get details of a specific session by ID + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `id` | string | yes | Session ID | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Session ID" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_init` + +Analyze the app and create AGENTS.md for a session. NOTE: This is a long-running operation that may take 30-60+ seconds depending on project size. + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `id` | string | yes | Session ID | +| `messageID` | string | yes | Message ID | +| `providerID` | string | yes | Provider ID (e.g. 'anthropic') | +| `modelID` | string | yes | Model ID (e.g. 'claude-3-5-sonnet-20241022') | +| `variant` | string | no | Model variant (e.g. 'fast', 'smart') | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Session ID" + }, + "messageID": { + "type": "string", + "description": "Message ID" + }, + "providerID": { + "type": "string", + "description": "Provider ID (e.g. 'anthropic')" + }, + "modelID": { + "type": "string", + "description": "Model ID (e.g. 'claude-3-5-sonnet-20241022')" + }, + "variant": { + "description": "Model variant (e.g. 'fast', 'smart')", + "type": "string" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "id", + "messageID", + "providerID", + "modelID" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_list` + +List all sessions + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_permission` + +Respond to a permission request in a session. Use `opencode_permission_list` to see pending requests. Reply values: 'once' (approve this request only), 'always' (approve this + future matching requests for this session), 'reject' (deny the request). + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `id` | string | yes | Session ID | +| `permissionID` | string | yes | Permission request ID | +| `reply` | "once" \| "always" \| "reject" | yes | Response to the permission request: 'once' to approve once, 'always' to auto-approve matching future requests, 'reject' to deny | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Session ID" + }, + "permissionID": { + "type": "string", + "description": "Permission request ID" + }, + "reply": { + "type": "string", + "enum": [ + "once", + "always", + "reject" + ], + "description": "Response to the permission request: 'once' to approve once, 'always' to auto-approve matching future requests, 'reject' to deny" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "id", + "permissionID", + "reply" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_revert` + +Revert a message in a session + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `id` | string | yes | Session ID | +| `messageID` | string | yes | Message ID to revert | +| `partID` | string | no | Part ID to revert (optional) | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Session ID" + }, + "messageID": { + "type": "string", + "description": "Message ID to revert" + }, + "partID": { + "description": "Part ID to revert (optional)", + "type": "string" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "id", + "messageID" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_search` + +Search sessions by keyword in title. Useful for finding a specific session among many. + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `query` | string | yes | Search keyword (case-insensitive match on session title) | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search keyword (case-insensitive match on session title)" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "query" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_share` + +Share a session publicly + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `id` | string | yes | Session ID to share | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Session ID to share" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_status` + +Get status for all sessions (running, idle, etc.) + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_summarize` + +Summarize a session using a specified model. NOTE: This is a long-running operation that may take 30-60+ seconds. + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `id` | string | yes | Session ID | +| `providerID` | string | yes | Provider ID (e.g. 'anthropic') | +| `modelID` | string | yes | Model ID (e.g. 'claude-3-5-sonnet-20241022') | +| `variant` | string | no | Model variant (e.g. 'fast', 'smart') | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Session ID" + }, + "providerID": { + "type": "string", + "description": "Provider ID (e.g. 'anthropic')" + }, + "modelID": { + "type": "string", + "description": "Model ID (e.g. 'claude-3-5-sonnet-20241022')" + }, + "variant": { + "description": "Model variant (e.g. 'fast', 'smart')", + "type": "string" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "id", + "providerID", + "modelID" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_todo` + +Get the todo list for a session + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `id` | string | yes | Session ID | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Session ID" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_unrevert` + +Restore all reverted messages in a session + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `id` | string | yes | Session ID | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Session ID" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_unshare` + +Unshare a previously shared session + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `id` | string | yes | Session ID to unshare | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Session ID to unshare" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_session_update` + +Update session properties (e.g. title) + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `id` | string | yes | Session ID | +| `title` | string | no | New title for the session | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Session ID" + }, + "title": { + "description": "New title for the session", + "type": "string" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +## Message Tools + +### `opencode_command_execute` + +Execute a slash command in a session (e.g. /init, /undo, /redo) + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `sessionId` | string | yes | Session ID | +| `command` | string | yes | The slash command to execute (e.g. 'init', 'undo') | +| `arguments` | string | no | Arguments for the command | +| `agent` | string | no | Agent to use | +| `providerID` | string | no | Provider ID | +| `modelID` | string | no | Model ID | +| `variant` | string | no | Model variant | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Session ID" + }, + "command": { + "type": "string", + "description": "The slash command to execute (e.g. 'init', 'undo')" + }, + "arguments": { + "description": "Arguments for the command", + "type": "string" + }, + "agent": { + "description": "Agent to use", + "type": "string" + }, + "providerID": { + "description": "Provider ID", + "type": "string" + }, + "modelID": { + "description": "Model ID", + "type": "string" + }, + "variant": { + "description": "Model variant", + "type": "string" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "sessionId", + "command" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_message_get` + +Get details of a specific message in a session + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `sessionId` | string | yes | Session ID | +| `messageId` | string | yes | Message ID | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Session ID" + }, + "messageId": { + "type": "string", + "description": "Message ID" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "sessionId", + "messageId" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_message_list` + +List all messages in a session with formatted output showing roles and content + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `sessionId` | string | yes | Session ID | +| `limit` | number | no | Maximum number of messages to return | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Session ID" + }, + "limit": { + "description": "Maximum number of messages to return", + "type": "number" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_message_send` + +Send a prompt message to a session and wait for the AI response. Use parts to send text, and optionally specify a model. + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `sessionId` | string | yes | Session ID | +| `text` | string | yes | The text message to send | +| `providerID` | string | no | Provider ID (e.g. 'anthropic') | +| `modelID` | string | no | Model ID (e.g. 'claude-3-5-sonnet-20241022') | +| `variant` | string | no | Model variant (e.g. 'fast', 'smart') | +| `agent` | string | no | Agent to use | +| `noReply` | boolean | no | If true, inject context without triggering AI response (useful for plugins) | +| `system` | string | no | System prompt override | +| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Session ID" + }, + "text": { + "type": "string", + "description": "The text message to send" + }, + "providerID": { + "description": "Provider ID (e.g. 'anthropic')", + "type": "string" + }, + "modelID": { + "description": "Model ID (e.g. 'claude-3-5-sonnet-20241022')", + "type": "string" + }, + "variant": { + "description": "Model variant (e.g. 'fast', 'smart')", + "type": "string" + }, + "agent": { + "description": "Agent to use", + "type": "string" + }, + "noReply": { + "description": "If true, inject context without triggering AI response (useful for plugins)", + "type": "boolean" + }, + "system": { + "description": "System prompt override", + "type": "string" + }, + "format": { + "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "json_schema" + }, + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "retryCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "schema" + ], + "additionalProperties": false + } + ] + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "sessionId", + "text" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_message_send_async` + +Send a prompt asynchronously and return its messageId. Pass sessionId and messageId to opencode_wait to observe this exact turn. + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `sessionId` | string | yes | Session ID | +| `text` | string | yes | The text message to send | +| `providerID` | string | no | Provider ID (e.g. 'anthropic') | +| `modelID` | string | no | Model ID (e.g. 'claude-3-5-sonnet-20241022') | +| `variant` | string | no | Model variant (e.g. 'fast', 'smart') | +| `agent` | string | no | Agent to use | +| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Session ID" + }, + "text": { + "type": "string", + "description": "The text message to send" + }, + "providerID": { + "description": "Provider ID (e.g. 'anthropic')", + "type": "string" + }, + "modelID": { + "description": "Model ID (e.g. 'claude-3-5-sonnet-20241022')", + "type": "string" + }, + "variant": { + "description": "Model variant (e.g. 'fast', 'smart')", + "type": "string" + }, + "agent": { + "description": "Agent to use", + "type": "string" + }, + "format": { + "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "json_schema" + }, + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "retryCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "schema" + ], + "additionalProperties": false + } + ] + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "sessionId", + "text" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_shell_execute` + +Run a shell command through the opencode session + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `sessionId` | string | yes | Session ID | +| `command` | string | yes | Shell command to execute | +| `agent` | string | yes | Agent to use for the shell command | +| `providerID` | string | no | Provider ID | +| `modelID` | string | no | Model ID | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Session ID" + }, + "command": { + "type": "string", + "description": "Shell command to execute" + }, + "agent": { + "type": "string", + "description": "Agent to use for the shell command" + }, + "providerID": { + "description": "Provider ID", + "type": "string" + }, + "modelID": { + "description": "Model ID", + "type": "string" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "sessionId", + "command", + "agent" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +## File & Search Tools + +### `opencode_file_list` + +List files and directories at a path + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `path` | string | no | Path to list (defaults to project root) | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "path": { + "description": "Path to list (defaults to project root)", + "type": "string" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_file_read` + +Read the content of a file + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `path` | string | yes | File path to read | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "File path to read" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_file_status` + +Get status for tracked files (VCS changes: modified, added, deleted, etc.) + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_find_file` + +Find files and directories by name (fuzzy match) + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `query` | string | yes | Search string for file/directory names | +| `type` | "file" \| "directory" | no | Limit results to 'file' or 'directory' | +| `searchDirectory` | string | no | Override the project root for the search | +| `limit` | number | no | Max number of results (1-200) | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search string for file/directory names" + }, + "type": { + "description": "Limit results to 'file' or 'directory'", + "type": "string", + "enum": [ + "file", + "directory" + ] + }, + "searchDirectory": { + "description": "Override the project root for the search", + "type": "string" + }, + "limit": { + "description": "Max number of results (1-200)", + "type": "number" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "query" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_find_symbol` + +Find workspace symbols by name (functions, classes, variables, etc.) + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `query` | string | yes | Symbol name to search for | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Symbol name to search for" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "query" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_find_text` + +Search for text patterns in project files (regex supported). Returns file paths, line numbers, and matching lines. + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `pattern` | string | yes | Text or regex pattern to search for in files | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Text or regex pattern to search for in files" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "pattern" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +## Config Tools + +### `opencode_config_get` + +Get the current opencode configuration + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_config_providers` + +List all configured providers and their default models + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_config_update` + +Update the opencode configuration. Pass a partial config object with fields to update. + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `config` | object | yes | Partial config object with fields to update | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "config": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "Partial config object with fields to update" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "config" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +## Provider & Auth Tools + +### `opencode_auth_set` + +Set authentication credentials for a provider (e.g. API key). Credentials are stored globally and shared across all projects. + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `providerId` | string | yes | Provider ID (e.g. 'anthropic') | +| `type` | string | yes | Auth type (e.g. 'api') | +| `key` | string | yes | API key or credential value | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "providerId": { + "type": "string", + "description": "Provider ID (e.g. 'anthropic')" + }, + "type": { + "type": "string", + "description": "Auth type (e.g. 'api')" + }, + "key": { + "type": "string", + "description": "API key or credential value" + } + }, + "required": [ + "providerId", + "type", + "key" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_provider_auth_methods` + +Get available authentication methods for all providers + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_provider_list` + +List all configured providers with their connection status. Returns a compact summary — use opencode_provider_models to see models for a specific provider. + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_provider_models` + +List available models for a specific provider. Call opencode_provider_list first to see provider IDs. + +**Profiles:** full, essential. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `providerId` | string | yes | Provider ID (e.g. 'anthropic', 'openrouter', 'google') | +| `limit` | number | no | Max models to show (default 30). Use 0 for all. | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "providerId": { + "type": "string", + "description": "Provider ID (e.g. 'anthropic', 'openrouter', 'google')" + }, + "limit": { + "description": "Max models to show (default 30). Use 0 for all.", + "type": "number" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "providerId" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_provider_oauth_authorize` + +Start OAuth authorization for a provider + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `providerId` | string | yes | Provider ID to authorize | +| `method` | integer | yes | Auth method index from opencode_provider_auth_methods (default 0); minimum: 0; maximum: 9007199254740991; default: 0 | +| `inputs` | object | no | Additional inputs requested by the selected authentication method | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "providerId": { + "type": "string", + "description": "Provider ID to authorize" + }, + "method": { + "default": 0, + "description": "Auth method index from opencode_provider_auth_methods (default 0)", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "inputs": { + "description": "Additional inputs requested by the selected authentication method", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "providerId", + "method" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_provider_oauth_callback` + +Handle OAuth callback for a provider + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `providerId` | string | yes | Provider ID | +| `callbackData` | object | yes | OAuth callback data | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "providerId": { + "type": "string", + "description": "Provider ID" + }, + "callbackData": { + "type": "object", + "properties": { + "method": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "The method index used for authorization" + }, + "code": { + "description": "Authorization code for methods that request one", + "type": "string" + } + }, + "required": [ + "method" + ], + "additionalProperties": false, + "description": "OAuth callback data" + } + }, + "required": [ + "providerId", + "callbackData" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +## TUI Control Tools + +### `opencode_tui_append_prompt` + +Append text to the TUI's prompt input field + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | Text to append to the prompt | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Text to append to the prompt" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_tui_clear_prompt` + +Clear the current prompt text in the TUI + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_tui_execute_command` + +Execute a slash command through the TUI (e.g. '/init', '/undo') + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `command` | string | yes | Command to execute (e.g. '/init') | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Command to execute (e.g. '/init')" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "command" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_tui_open_help` + +Open the help dialog in the TUI + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_tui_open_models` + +Open the model selector in the TUI + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_tui_open_sessions` + +Open the session selector in the TUI + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_tui_open_themes` + +Open the theme selector in the TUI + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_tui_show_toast` + +Show a toast notification in the TUI + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `message` | string | yes | Toast message text | +| `title` | string | no | Optional toast title | +| `variant` | "info" \| "success" \| "warning" \| "error" | no | Toast variant (default: info) | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Toast message text" + }, + "title": { + "description": "Optional toast title", + "type": "string" + }, + "variant": { + "description": "Toast variant (default: info)", + "type": "string", + "enum": [ + "info", + "success", + "warning", + "error" + ] + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "message" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_tui_submit_prompt` + +Submit the current prompt in the TUI (equivalent to pressing Enter) + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +## Project Tools + +### `opencode_project_current` + +Get the current active project + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_project_init` + +Initialize or open a project directory to host an independent OpenCode session. Use this to create new empty folders, or to explicitly open preexisting projects on the MCP host machine for parallel code generation workloads. This tool operates on the local filesystem; create remote server directories separately. + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `path` | string | yes | The absolute file path where the project directory is located or should be created. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The absolute file path where the project directory is located or should be created." + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_project_list` + +List all projects known to the opencode server + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +## Event Tools + +### `opencode_events_poll` + +Poll project events from OpenCode, or explicitly select global scope. Collects up to maxEvents within the duration. Connection failures are reported with any partial events; stopping observation does not abort remote work. + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `durationMs` | integer | no | How long to collect events in milliseconds (default: 3000, max: 30000); minimum: 1; maximum: 30000 | +| `maxEvents` | integer | no | Maximum number of events to collect (default: 50, max: 1000); minimum: 1; maximum: 1000 | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | +| `scope` | "project" \| "global" | no | Event scope (default: project). Global events include all projects and cannot be combined with directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "durationMs": { + "description": "How long to collect events in milliseconds (default: 3000, max: 30000)", + "type": "integer", + "minimum": 1, + "maximum": 30000 + }, + "maxEvents": { + "description": "Maximum number of events to collect (default: 50, max: 1000)", + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + }, + "scope": { + "description": "Event scope (default: project). Global events include all projects and cannot be combined with directory.", + "type": "string", + "enum": [ + "project", + "global" + ] + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +## Global Tools + +### `opencode_health` + +Check server health and version + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +## System & Monitoring Tools + +### `opencode_agent_list` + +List all available agents with their names, descriptions, and modes (primary/subagent) + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_command_list` + +List all available commands (built-in and custom slash commands) + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_formatter_status` + +Get the status of configured formatters + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_instance_dispose` + +Dispose the current opencode instance (shuts it down). WARNING: This is destructive and will terminate the server. + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_log` + +Write a log entry to the opencode server + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `service` | string | yes | Service name for the log entry | +| `level` | "debug" \| "info" \| "warn" \| "error" | yes | Log level | +| `message` | string | yes | Log message | +| `extra` | object | no | Extra data to include in the log entry | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "service": { + "type": "string", + "description": "Service name for the log entry" + }, + "level": { + "type": "string", + "enum": [ + "debug", + "info", + "warn", + "error" + ], + "description": "Log level" + }, + "message": { + "type": "string", + "description": "Log message" + }, + "extra": { + "description": "Extra data to include in the log entry", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "service", + "level", + "message" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_lsp_status` + +Get the status of LSP (Language Server Protocol) servers + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_mcp_add` + +Add an MCP server dynamically to opencode + +**Profiles:** full. + +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `name` | string | yes | Name for the MCP server | +| `config` | object | yes | MCP server configuration object | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name for the MCP server" + }, + "config": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "MCP server configuration object" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "name", + "config" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_mcp_status` + +Get the status of all MCP servers configured in opencode + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_path_get` + +Get the current working path of the opencode server + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_tool_ids` + +List all available tool IDs that the LLM can use (experimental) + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_tool_list` + +List tools with JSON schemas for a given provider and model (experimental) + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `provider` | string | yes | Provider ID | +| `model` | string | yes | Model ID | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Provider ID" + }, + "model": { + "type": "string", + "description": "Model ID" + }, + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` + +
+ +### `opencode_vcs_info` + +Get VCS (version control) info for the current project (branch, remote, status) + +**Profiles:** full. + +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. + +#### Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | + +#### Structured output + +| Field | Type | Required | Description | +|---|---|---|---| +| `text` | string | yes | | +| `isError` | boolean | yes | | +| `data` | unknown | no | | + +
+Complete input and output JSON schemas + +```json +{ + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" + } + }, + "additionalProperties": false + }, + "outputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "isError": { + "type": "boolean" + }, + "data": {} + }, + "required": [ + "text", + "isError" + ], + "additionalProperties": {} + } +} +``` -| Tool | Key Parameters | Description | -|---|---|---| -| `opencode_log` | `service`, `level`, `message`, `extra?` | Write a log entry | +
diff --git a/package-lock.json b/package-lock.json index 9135c4e..a65b28a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,33 +1,99 @@ { "name": "opencode-mcp", - "version": "2.0.1", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-mcp", - "version": "2.0.1", + "version": "3.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1", - "@opencode-ai/sdk": "^1.18.31" + "@modelcontextprotocol/core": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "@opencode-ai/sdk": "^1.18.31", + "zod": "^4.6.5" }, "bin": { "opencode-mcp": "dist/index.js" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/sdk": "^1.30.0", "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^4.1.11", "typescript": "^5.7.0", "vitest": "^4.0.18" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" } }, "node_modules/@hono/node-server": { "version": "1.19.17", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18.14.1" @@ -36,6 +102,16 @@ "hono": "^4" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", @@ -43,13 +119,56 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "dev": true, "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -83,6 +202,19 @@ } } }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@opencode-ai/sdk": { "version": "1.18.31", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.31.tgz", @@ -424,6 +556,37 @@ "undici-types": "~6.21.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", @@ -541,6 +704,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, "license": "MIT", "dependencies": { "mime-types": "^3.0.0", @@ -554,6 +718,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -570,6 +735,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -593,10 +759,23 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.6.tgz", + "integrity": "sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -621,6 +800,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -634,6 +814,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -643,6 +824,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -656,6 +838,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -682,6 +865,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -695,6 +879,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -711,6 +896,7 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -720,6 +906,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.6.0" @@ -729,6 +916,7 @@ "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, "license": "MIT", "dependencies": { "object-assign": "^4", @@ -760,6 +948,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -777,6 +966,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -796,6 +986,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -810,12 +1001,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, "license": "MIT" }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -825,6 +1018,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -834,6 +1028,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -850,6 +1045,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -862,6 +1058,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, "license": "MIT" }, "node_modules/estree-walker": { @@ -878,6 +1075,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -887,6 +1085,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -899,6 +1098,7 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -918,6 +1118,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, "license": "MIT", "dependencies": { "accepts": "^2.0.0", @@ -961,6 +1162,7 @@ "version": "8.7.0", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -980,12 +1182,14 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "dev": true, "funding": [ { "type": "github", @@ -1020,6 +1224,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -1041,6 +1246,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -1050,6 +1256,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -1074,6 +1281,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -1083,6 +1291,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -1107,6 +1316,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -1120,6 +1330,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1128,10 +1339,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1144,6 +1366,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1156,15 +1379,24 @@ "version": "4.13.7", "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=16.9.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, "license": "MIT", "dependencies": { "depd": "~2.0.0", @@ -1185,6 +1417,7 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -1201,12 +1434,14 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/ip-address": { "version": "10.7.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -1216,6 +1451,7 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.10" @@ -1225,6 +1461,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, "license": "MIT" }, "node_modules/isexe": { @@ -1233,25 +1470,74 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-typed": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/lightningcss": { @@ -1537,10 +1823,39 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1550,6 +1865,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -1559,6 +1875,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1571,6 +1888,7 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -1580,6 +1898,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -1596,6 +1915,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -1621,6 +1941,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -1630,6 +1951,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -1639,6 +1961,7 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1662,6 +1985,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -1674,6 +1998,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -1683,6 +2008,7 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -1701,6 +2027,7 @@ "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -1738,6 +2065,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=16.20.0" @@ -1776,6 +2104,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -1789,6 +2118,7 @@ "version": "6.16.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -1805,6 +2135,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -1814,6 +2145,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -1829,6 +2161,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -1872,6 +2205,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -1888,12 +2222,27 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -1920,6 +2269,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -1939,6 +2289,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, "license": "ISC" }, "node_modules/shebang-command": { @@ -1966,6 +2317,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1985,6 +2337,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2001,6 +2354,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -2019,6 +2373,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -2062,6 +2417,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -2074,6 +2430,19 @@ "dev": true, "license": "MIT" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2122,6 +2491,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.6" @@ -2131,6 +2501,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, "license": "MIT", "dependencies": { "content-type": "^2.0.0", @@ -2149,6 +2520,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2183,6 +2555,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -2192,6 +2565,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -2401,12 +2775,13 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -2416,6 +2791,7 @@ "version": "3.25.1", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "dev": true, "license": "ISC", "peerDependencies": { "zod": "^3.25 || ^4" diff --git a/package.json b/package.json index ff0c163..a2ce46f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opencode-mcp", - "version": "2.0.1", - "description": "MCP server that wraps the OpenCode AI headless server API \u2014 80 tools, 10 resources, 6 prompts with multi-project support for any MCP client", + "version": "3.0.0", + "description": "MCP server for OpenCode coding workflows, durable async jobs, project resources, and explicit user input", "type": "module", "main": "dist/index.js", "bin": { @@ -22,7 +22,9 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "prepublishOnly": "npm run build", - "pretest": "npm run build" + "pretest": "npm run build", + "docs:generate": "npm run build && node scripts/generate-tool-docs.mjs", + "docs:check": "npm run build && node scripts/generate-tool-docs.mjs --check" }, "keywords": [ "mcp", @@ -49,14 +51,19 @@ "author": "Alaeddine Messadi", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1", - "@opencode-ai/sdk": "^1.18.31" + "@modelcontextprotocol/core": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "@opencode-ai/sdk": "^1.18.31", + "zod": "^4.6.5" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/sdk": "^1.30.0", "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^4.1.11", "typescript": "^5.7.0", "vitest": "^4.0.18" } diff --git a/scripts/generate-tool-docs.mjs b/scripts/generate-tool-docs.mjs new file mode 100644 index 0000000..4863f2e --- /dev/null +++ b/scripts/generate-tool-docs.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +// Deterministic reference generated from the exact registration catalogs. +// No network requests, model calls, or job-store writes are permitted here. +import { readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { createServer } from "../dist/app.js"; + +const offline = new Proxy({}, { + get: (_target, property) => () => { throw new Error(`Documentation generation attempted runtime operation: ${String(property)}`); }, +}); +const full = createServer(offline, offline, "full").catalog; +const essential = new Set(createServer(offline, offline, "essential").catalog.map((tool) => tool.name)); +const workflows = new Set("setup ask reply run fire check conversation sessions_overview context review_changes wait provider_test status".split(" ").map((name) => `opencode_${name}`)); +const groups = [ + ["Workflow Tools", (n) => workflows.has(n)], + ["Job Tools", (n) => n.startsWith("opencode_job_")], + ["Question Tools", (n) => n.startsWith("opencode_question_")], + ["Session Tools", (n) => n.startsWith("opencode_session_") || n === "opencode_permission_list"], + ["Message Tools", (n) => /^opencode_(message_|command_execute$|shell_execute$)/.test(n)], + ["File & Search Tools", (n) => /^opencode_(file_|find_)/.test(n)], + ["Config Tools", (n) => n.startsWith("opencode_config_")], + ["Provider & Auth Tools", (n) => /^opencode_(provider_|auth_)/.test(n)], + ["TUI Control Tools", (n) => n.startsWith("opencode_tui_")], + ["Project Tools", (n) => n.startsWith("opencode_project_")], + ["Event Tools", (n) => n.startsWith("opencode_events_")], + ["Global Tools", (n) => n === "opencode_health"], + ["System & Monitoring Tools", () => true], +]; +const cell = (value) => String(value ?? "").replaceAll("|", "\\|").replaceAll("\n", " ").replaceAll("\r", " "); +const anchor = (value) => value.toLowerCase().replaceAll(" & ", "--").replaceAll(" ", "-"); +function type(schema) { + if (schema.enum) return schema.enum.map((value) => JSON.stringify(value)).join(" | "); + if (Object.hasOwn(schema, "const")) return JSON.stringify(schema.const); + if (schema.anyOf || schema.oneOf) return (schema.anyOf ?? schema.oneOf).map(type).join(" | "); + if (schema.type === "array") return `array<${type(schema.items ?? {})}>`; + if (Array.isArray(schema.type)) return schema.type.join(" | "); + return schema.type ?? (schema.$ref ? schema.$ref : "unknown"); +} +function details(schema) { + const constraints = [ + ["minimum", "minimum"], ["maximum", "maximum"], ["minLength", "minimum length"], + ["maxLength", "maximum length"], ["minItems", "minimum items"], ["maxItems", "maximum items"], + ["pattern", "pattern"], ["default", "default"], + ].filter(([key]) => Object.hasOwn(schema, key)).map(([key, label]) => `${label}: ${JSON.stringify(schema[key])}`); + return [schema.description, ...constraints].filter(Boolean).join("; "); +} +function properties(schema, input = false) { + const entries = Object.entries(schema.properties ?? {}); + if (!entries.length) return input ? "No input parameters.\n" : "No fixed output fields.\n"; + return [ + `| ${input ? "Parameter" : "Field"} | Type | Required | Description |`, + "|---|---|---|---|", + ...entries.map(([name, field]) => `| \`${name}\` | ${cell(type(field))} | ${(schema.required ?? []).includes(name) ? "yes" : "no"} | ${cell(details(field))} |`), + ].join("\n") + "\n"; +} +const sections = new Map(groups.map(([name]) => [name, []])); +const names = new Set(); +for (const tool of [...full].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) { + if (names.has(tool.name)) throw new Error(`Duplicate tool: ${tool.name}`); + names.add(tool.name); + sections.get(groups.find(([, matches]) => matches(tool.name))[0]).push(tool); +} +const lines = [ + "# Tools Reference", "", + "", "", + `The full profile exposes **${full.length} tools**; the essential profile exposes **${essential.size} tools**. This reference is generated from the registered input/output schemas and behavior annotations.`, "", + "Set `OPENCODE_TOOL_PROFILE=essential` for the smaller catalog; `full` is the default. Profile availability is listed for each tool. Profiles control discovery, not OpenCode permissions.", "", + "## Input and Output Contracts", "", + "When present, `directory` means an absolute path on the OpenCode server. Global authentication tools omit it; local project initialization uses `path`. See [configuration](configuration.md) for scope and [examples](examples.md) for workflows.", "", + "Results retain readable MCP `content` and provide `structuredContent`. Structured outputs include `text` and `isError`; generic JSON lives in `data`, while async tools provide identifiers and state fields. Tool errors also set the MCP result's `isError`. Inspect state rather than parsing text. An observation timeout can return a running state without an error or cancellation.", "", + "Async states are `accepted`, `running`, `input_required`, `completed`, `failed`, `cancelled`, and `unknown`. Native MCP task execution is negotiated separately from ordinary tool calls. See [architecture](architecture.md).", "", + "Annotations are behavior hints, not an authorization boundary. Nested structures and constraints are included in each tool's expandable JSON schema.", "", + "## Categories", "", + ...[...sections].filter(([, tools]) => tools.length).map(([name, tools]) => `- [${name} (${tools.length})](#${anchor(name)})`), "", +]; +for (const [section, tools] of sections) { + if (!tools.length) continue; + lines.push(`## ${section}`, ""); + for (const tool of tools) { + lines.push(`### \`${tool.name}\``, "", tool.description, "", + `**Profiles:** ${essential.has(tool.name) ? "full, essential" : "full"}.`, "", + `**Annotations:** ${Object.entries(tool.annotations).map(([name, value]) => `\`${name}: ${value}\``).join(", ")}.`, "", + "#### Parameters", "", properties(tool.inputSchema, true), + "#### Structured output", "", properties(tool.outputSchema), + "
", "Complete input and output JSON schemas", "", + "```json", JSON.stringify({ inputSchema: tool.inputSchema, outputSchema: tool.outputSchema }, null, 2), "```", "", "
", ""); + } +} +const output = lines.join("\n"); +const destination = fileURLToPath(new URL("../docs/tools.md", import.meta.url)); +if (process.argv.includes("--check")) { + const current = await readFile(destination, "utf8"); + // Git may check out Markdown with CRLF on Windows; compare logical lines. + if (current.replaceAll("\r\n", "\n") !== output) { + console.error("Tool reference is stale. Run npm run docs:generate and commit docs/tools.md."); + process.exitCode = 1; + } +} else { + await writeFile(destination, output); + console.log(`Generated docs/tools.md (${full.length} full / ${essential.size} essential tools)`); +} diff --git a/scripts/mcp-smoke-test.mjs b/scripts/mcp-smoke-test.mjs index 2328747..c02018b 100644 --- a/scripts/mcp-smoke-test.mjs +++ b/scripts/mcp-smoke-test.mjs @@ -1,506 +1,197 @@ #!/usr/bin/env node -// Smoke test runner for opencode-mcp. -// Spawns the MCP server over stdio and calls tools to verify behavior. - +// Live smoke checks. Import runSmoke for deterministic injected-client tests. +// The legacy client deliberately verifies interoperability with MCP v1 clients. import process from "node:process"; -import { setTimeout as delay } from "node:timers/promises"; - +import { mkdtemp, realpath, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { execFileSync } from "node:child_process"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -const REPO_DIR = process.cwd(); -const DIRECTORY = process.env.OPENCODE_TEST_DIRECTORY ?? REPO_DIR; -const OPENCODE_BASE_URL = process.env.OPENCODE_BASE_URL ?? "http://127.0.0.1:4096"; - -function now() { - return new Date().toISOString(); -} - -function shorten(s, n = 300) { - if (typeof s !== "string") return String(s); - if (s.length <= n) return s; - return s.slice(0, n) + "..."; -} +export const FIXTURE_MARKER = "opencode-mcp-smoke-fixture"; +const textOf = (result) => (result?.content ?? []) + .filter((part) => part.type === "text").map((part) => part.text).join("\n"); -function toolText(result) { - const parts = result?.content; - if (!Array.isArray(parts)) return ""; - return parts.map((p) => (p && typeof p.text === "string" ? p.text : "")).join("\n"); +export function smokeOptions(argv = [], env = process.env) { + const options = { + baseUrl: env.OPENCODE_BASE_URL ?? "http://127.0.0.1:4096", + inference: env.OPENCODE_TEST_INFERENCE === "true", + providerID: env.OPENCODE_TEST_PROVIDER, + modelID: env.OPENCODE_TEST_MODEL, + }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--inference") options.inference = true; + else if (["--provider", "--model", "--base-url"].includes(argv[i])) { + const key = { "--provider": "providerID", "--model": "modelID", "--base-url": "baseUrl" }[argv[i]]; + if (!argv[i + 1] || argv[i + 1].startsWith("--")) throw new Error("Missing smoke option value"); + options[key] = argv[++i]; + } else throw new Error("Unknown smoke option"); + } + if (options.inference && (!options.providerID?.trim() || !options.modelID?.trim())) { + throw new Error("Inference requires --provider and --model (or OPENCODE_TEST_PROVIDER and OPENCODE_TEST_MODEL)"); + } + const url = new URL(options.baseUrl); + if (url.username || url.password) throw new Error("Use OPENCODE_SERVER_USERNAME/PASSWORD instead of URL credentials"); + if (!["http:", "https:"].includes(url.protocol) || !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) { + throw new Error("Smoke checks require a local OpenCode server so a disposable project can be isolated on the same filesystem"); + } + return options; } -function extractMessageIdsFromListOutput(text) { - if (!text) return []; - const ids = []; - const re = /--- Message\s+\d+\s+\[[^\]]+\]\s+\(([^)]+)\)\s+---/g; - let m; - while ((m = re.exec(text))) { - if (m[1]) ids.push(m[1]); +export function smokeEnvironment(options, env = process.env) { + const childEnv = { OPENCODE_BASE_URL: options.baseUrl, OPENCODE_AUTO_SERVE: "false" }; + for (const key of ["OPENCODE_SERVER_USERNAME", "OPENCODE_SERVER_PASSWORD"]) { + if (env[key] !== undefined) childEnv[key] = env[key]; } - return ids; + return childEnv; } -async function fetchJson(url) { - const res = await fetch(url, { headers: { "content-type": "application/json" } }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`HTTP ${res.status} for ${url}: ${shorten(text, 500)}`); +export async function createFixture() { + const directory = await realpath(await mkdtemp(join(tmpdir(), "opencode-mcp-smoke-"))); + try { + await writeFile(join(directory, "README.md"), `${FIXTURE_MARKER}\n`); + // Deny model tool use: inference checks only need a text response. + await writeFile(join(directory, "opencode.json"), JSON.stringify({ permission: "deny" })); + execFileSync("git", ["-c", "init.templateDir=", "init", "--quiet", directory], { stdio: "pipe" }); + execFileSync("git", ["-C", directory, "add", "README.md", "opencode.json"], { stdio: "pipe" }); + return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) }; + } catch { + await rm(directory, { recursive: true, force: true }); + throw new Error("Could not create disposable smoke project (Git must be installed)"); } - return await res.json(); } -function isProviderConfigured(p) { - const source = p?.source; - if (source === "env" || source === "config" || source === "api") return true; - if (source === "custom") { - const opts = p?.options; - if (opts && typeof opts.apiKey === "string" && opts.apiKey !== "") return true; - if (p?.id === "anthropic" && opts && Object.keys(opts).some((k) => k !== "apiKey")) return true; - } - return false; +function createdSessionId(result) { + if (result?.isError) return undefined; + const structured = result?.structuredContent; + const candidate = structured?.sessionId ?? structured?.data?.sessionId ?? structured?.data?.id ?? structured?.id; + if (candidate !== undefined) return typeof candidate === "string" && /^ses_[A-Za-z0-9_-]+$/.test(candidate) ? candidate : undefined; + // Legacy compatibility: ONLY the successful session_create response is parsed. + const matches = [...textOf(result).matchAll(/^ID: (ses_[A-Za-z0-9_-]+)$/gm)]; + return matches.length === 1 ? matches[0][1] : undefined; } -function extractFirstModelId(provider) { - const rawModels = provider?.models; - const modelList = Array.isArray(rawModels) - ? rawModels - : rawModels && typeof rawModels === "object" - ? Object.values(rawModels) - : []; - for (const m of modelList) { - const id = m?.id ?? m?.name; - if (typeof id === "string" && id) return id; - } - return null; +function completed(result) { + const data = result?.structuredContent; + const status = data?.status ?? data?.state ?? data?.data?.status; + if (status !== undefined) return status === "completed"; + return /^Status: completed\b/m.test(textOf(result)) || /Session .* (?:is idle|completed)/i.test(textOf(result)); } -async function pickTestModel() { - const raw = await fetchJson(`${OPENCODE_BASE_URL}/provider`); - const providers = Array.isArray(raw?.all) ? raw.all : Array.isArray(raw) ? raw : []; - const configured = providers.filter(isProviderConfigured); - for (const p of configured) { - const providerId = p?.id; - if (typeof providerId !== "string" || !providerId) continue; - const modelId = extractFirstModelId(p); - if (modelId) return { providerId, modelId }; - } - return null; -} - -async function main() { +export async function runSmoke({ client, directory, inference = false, providerID, modelID }) { + if (!directory) throw new Error("A disposable fixture directory is required"); + if (inference && (!providerID || !modelID)) throw new Error("Inference requires explicit provider and model"); const results = []; - const record = (name, status, detail = "") => { - results.push({ name, status, detail }); - }; - - const calledTools = new Set(); - const skippedTools = new Set(); - - console.log(`[${now()}] Starting opencode-mcp smoke test`); - console.log(`- Repo: ${REPO_DIR}`); - console.log(`- Directory: ${DIRECTORY}`); - console.log(`- OpenCode: ${OPENCODE_BASE_URL}`); - - // Pick a configured provider+model for message tests. - let testModel = null; - try { - testModel = await pickTestModel(); - } catch (e) { - record("preflight:pickTestModel", "FAIL", e instanceof Error ? e.message : String(e)); - } - if (!testModel) { - record( - "preflight:pickTestModel", - "SKIP", - "No configured provider/model detected from /provider; message-based tools may return empty responses.", - ); - } else { - record("preflight:pickTestModel", "PASS", `${testModel.providerId}/${testModel.modelId}`); - } - - const transport = new StdioClientTransport({ - command: "node", - args: ["dist/index.js"], - cwd: REPO_DIR, - stderr: "pipe", - env: { - OPENCODE_BASE_URL, - // Don't auto-start a server during smoke tests. - OPENCODE_AUTO_SERVE: "false", - }, - }); - - const client = new Client({ name: "opencode-mcp-smoke", version: "0.0.0" }); - if (transport.stderr) { - transport.stderr.on("data", (buf) => { - const s = String(buf); - // Keep stderr quiet unless it looks meaningful. - if (s.includes("Fatal") || s.includes("Error") || s.includes("Warning")) { - process.stderr.write(s); - } - }); - } - - try { - await client.connect(transport); - } catch (e) { - console.error("Failed to connect to MCP server:", e); - process.exit(1); - } - - // List tools. - let tools = []; - try { - const list = await client.listTools(); - tools = list.tools ?? []; - record("tools/list", "PASS", `${tools.length} tools`); - } catch (e) { - record("tools/list", "FAIL", e instanceof Error ? e.message : String(e)); - } - - const toolNames = new Set(tools.map((t) => t.name)); - const expected = [ - "opencode_setup", - "opencode_ask", - "opencode_reply", - "opencode_provider_test", - "opencode_status", - "opencode_session_search", - ]; - for (const name of expected) { - record( - `presence:${name}`, - toolNames.has(name) ? "PASS" : "FAIL", - toolNames.has(name) ? "" : "Missing tool", - ); - } - - // Helper to call tool and validate it returns text. - async function call(name, args, opts = {}) { - const { allowError = false, validateText = true } = opts; - const started = Date.now(); + const called = new Set(); + const owned = new Set(); + let listed = []; + const record = (name, status, detail = "") => results.push({ name, status, detail }); + async function call(name, args = {}, validate) { + called.add(name); try { - console.log(`- call ${name}`); - calledTools.add(name); - const res = await client.callTool({ name, arguments: args }); - const t = toolText(res); - const ms = Date.now() - started; - if (!allowError && res?.isError) { - record(name, "FAIL", `isError=true in ${ms}ms: ${shorten(t, 600)}`); - return { ok: false, res, text: t }; - } - if (validateText && (!t || t.trim() === "")) { - record(name, "FAIL", `Empty text in ${ms}ms`); - return { ok: false, res, text: t }; - } - record(name, "PASS", `${ms}ms`); - return { ok: true, res, text: t }; - } catch (e) { - const ms = Date.now() - started; - record(name, "FAIL", `Exception in ${ms}ms: ${e instanceof Error ? e.message : String(e)}`); - return { ok: false, res: null, text: "" }; + const arguments_ = name === "opencode_project_init" ? args : { ...args, directory }; + const result = await client.callTool({ name, arguments: arguments_ }, undefined, { timeout: 180_000 }); + if (result?.isError) throw new Error("Tool returned isError"); + if (!textOf(result).trim() && !result?.structuredContent) throw new Error("Tool returned no content"); + if (validate && !validate(result)) throw new Error("Response did not satisfy the fixture assertion"); + record(name, "PASS"); + return result; + } catch { + // Do not echo responses/exceptions: they can contain server credentials/config. + record(name, "FAIL", "Tool failed or its response did not satisfy the fixture assertion"); + return undefined; } } - - // --- Core health + context --- - await call("opencode_health", { directory: DIRECTORY }); - await call("opencode_status", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_setup", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_context", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_path_get", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_vcs_info", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_project_current", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_project_list", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_config_get", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_config_providers", { directory: DIRECTORY }, { allowError: true }); - - // --- Providers --- - await call("opencode_provider_list", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_provider_auth_methods", { directory: DIRECTORY }, { allowError: true }); - if (testModel) { - await call( - "opencode_provider_models", - { providerId: testModel.providerId, directory: DIRECTORY, limit: 5 }, - { allowError: true }, - ); - } else { - record("opencode_provider_models", "SKIP", "No test model available"); - } - - // --- Sessions + messages (use a temp session) --- - let sessionId = null; try { - const created = await call( - "opencode_session_create", - { title: "[mcp-smoke] tools", directory: DIRECTORY }, - { allowError: true }, - ); - const text = created.text; - // Try to extract ID from formatted output - const m = text.match(/\bID:\s*([A-Za-z0-9_-]+)\b/); - sessionId = m ? m[1] : null; - if (!sessionId) { - // fallback: list sessions and pick the most recent by title - const list = await call("opencode_session_list", { directory: DIRECTORY }, { allowError: true }); - const lt = list.text; - const m2 = lt.match(/\[([A-Za-z0-9_-]+)\]/); - if (m2) sessionId = m2[1]; - } - if (sessionId) { - record("session:temp", "PASS", sessionId); - } else { - record("session:temp", "FAIL", `Could not extract session ID from: ${shorten(text, 300)}`); - } - } catch (e) { - record("session:temp", "FAIL", e instanceof Error ? e.message : String(e)); - } - - if (sessionId) { - await call("opencode_session_list", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_session_get", { id: sessionId, directory: DIRECTORY }, { allowError: true }); - await call("opencode_session_update", { id: sessionId, title: "[mcp-smoke] tools updated", directory: DIRECTORY }, { allowError: true }); - await call("opencode_session_children", { id: sessionId, directory: DIRECTORY }, { allowError: true }); - await call("opencode_session_status", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_session_todo", { id: sessionId, directory: DIRECTORY }, { allowError: true }); - await call("opencode_session_diff", { id: sessionId, directory: DIRECTORY }, { allowError: true }); - await call("opencode_review_changes", { sessionId, directory: DIRECTORY }, { allowError: true }); - await call("opencode_sessions_overview", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_session_search", { query: "mcp-smoke", directory: DIRECTORY }, { allowError: true }); - - // Permission tool is hard to test end-to-end without a real permission request. - // Still verify it responds (likely error) and doesn't crash. - await call( - "opencode_session_permission", - { id: sessionId, permissionID: "perm-does-not-exist", response: "accept", directory: DIRECTORY }, - { allowError: true }, - ); - - // Fork + abort (best-effort) - await call("opencode_session_fork", { id: sessionId, directory: DIRECTORY }, { allowError: true }); - await call("opencode_session_abort", { id: sessionId, directory: DIRECTORY }, { allowError: true }); - - // Share/unshare (best-effort) - await call("opencode_session_share", { id: sessionId, directory: DIRECTORY }, { allowError: true }); - await call("opencode_session_unshare", { id: sessionId, directory: DIRECTORY }, { allowError: true }); - - // Message list + conversation - const msgList = await call( - "opencode_message_list", - { sessionId, directory: DIRECTORY, limit: 10 }, - { allowError: true }, - ); - await call("opencode_conversation", { sessionId, directory: DIRECTORY, limit: 10 }, { allowError: true }); - - if (testModel) { - // message_send - await call( - "opencode_message_send", - { - sessionId, - text: "Return the number 7.", - providerID: testModel.providerId, - modelID: testModel.modelId, - directory: DIRECTORY, - }, - { allowError: true }, - ); - - // Refresh message list so we can test message_get + revert/unrevert - const msgList2 = await call( - "opencode_message_list", - { sessionId, directory: DIRECTORY, limit: 10 }, - { allowError: true }, - ); - const ids = extractMessageIdsFromListOutput(msgList2.text); - const lastId = ids.length > 0 ? ids[ids.length - 1] : null; - if (lastId) { - await call("opencode_message_get", { sessionId, messageId: lastId, directory: DIRECTORY }, { allowError: true }); - await call("opencode_session_revert", { id: sessionId, messageID: lastId, directory: DIRECTORY }, { allowError: true }); - await call("opencode_session_unrevert", { id: sessionId, directory: DIRECTORY }, { allowError: true }); - } else { - record("opencode_message_get", "SKIP", "Could not extract messageId from message_list output"); - record("opencode_session_revert", "SKIP", "Could not extract messageId from message_list output"); - record("opencode_session_unrevert", "SKIP", "No revert executed"); + let cursor; + const seen = new Set(); + do { + const page = await client.listTools(cursor ? { cursor } : {}); + listed.push(...(page.tools ?? [])); + cursor = page.nextCursor; + if (cursor && seen.has(cursor)) throw new Error("Repeated tools cursor"); + if (cursor) seen.add(cursor); + } while (cursor); + record("tools/list", listed.length ? "PASS" : "FAIL", `${listed.length} tools`); + const healthy = await call("opencode_health", {}, (r) => /\bStatus: healthy\b/.test(textOf(r)) || r.structuredContent?.healthy === true || r.structuredContent?.data?.healthy === true); + if (healthy) { + await call("opencode_project_init", { path: directory }); + for (const name of ["opencode_project_current", "opencode_path_get", "opencode_vcs_info", "opencode_agent_list", "opencode_command_list", "opencode_provider_list", "opencode_provider_auth_methods", "opencode_config_providers", "opencode_file_status", "opencode_session_status"]) { + await call(name); + } + await call("opencode_file_list", { path: "." }, (r) => textOf(r).includes("README.md")); + await call("opencode_file_read", { path: "README.md" }, (r) => textOf(r).includes(FIXTURE_MARKER)); + await call("opencode_find_file", { query: "README.md" }, (r) => textOf(r).includes("README.md")); + await call("opencode_find_text", { pattern: FIXTURE_MARKER }, (r) => textOf(r).includes(FIXTURE_MARKER) && !textOf(r).startsWith("No matches")); + const created = await call("opencode_session_create", { title: "[mcp-smoke] disposable fixture" }, (r) => Boolean(createdSessionId(r))); + const sessionId = createdSessionId(created); + if (sessionId) { + owned.add(sessionId); + await call("opencode_session_get", { id: sessionId }); + await call("opencode_session_todo", { id: sessionId }); + await call("opencode_message_list", { sessionId }); + await call("opencode_conversation", { sessionId }); + await call("opencode_check", { sessionId }); + if (inference) { + const args = { sessionId, prompt: "Reply with exactly SMOKE_OK. Do not use tools.", providerID, modelID }; + const fired = await call("opencode_fire", args); + await call("opencode_check", { sessionId }); + const waited = fired && await call("opencode_wait", { sessionId, timeoutSeconds: 120 }, completed); + // Never enqueue a second prompt while the first may still be running. + if (waited) await call("opencode_run", { ...args, maxDurationSeconds: 120 }, completed); + } } - - // message_send_async + wait - await call( - "opencode_message_send_async", - { - sessionId, - text: "Reply with the word OK.", - providerID: testModel.providerId, - modelID: testModel.modelId, - directory: DIRECTORY, - }, - { allowError: true }, - ); - await delay(1000); - await call("opencode_wait", { sessionId, timeoutSeconds: 30, pollIntervalMs: 1000, directory: DIRECTORY }, { allowError: true }); - - // Ask/reply workflows - await call( - "opencode_ask", - { - prompt: "Reply with just the word PONG.", - providerID: testModel.providerId, - modelID: testModel.modelId, - directory: DIRECTORY, - }, - { allowError: true }, - ); - await call( - "opencode_reply", - { - sessionId, - prompt: "Reply with just the word DONE.", - providerID: testModel.providerId, - modelID: testModel.modelId, - directory: DIRECTORY, - }, - { allowError: true }, - ); - - // Slash command (best-effort; depends on server) - await call( - "opencode_command_execute", - { sessionId, command: "help", arguments: "", directory: DIRECTORY }, - { allowError: true }, - ); - } else { - record("message tools", "SKIP", "No configured provider/model available for message tests"); } - - // provider_test with explicit model (should pass if provider works) - if (testModel) { - await call( - "opencode_provider_test", - { providerId: testModel.providerId, modelID: testModel.modelId, directory: DIRECTORY }, - { allowError: true }, - ); - // provider_test without modelID: should still work ideally - await call( - "opencode_provider_test", - { providerId: testModel.providerId, directory: DIRECTORY }, - { allowError: true }, - ); + } catch { + record("smoke", "FAIL", "MCP discovery or smoke execution failed"); + } finally { + for (const sessionId of owned) { + // Abort before deleting: timeout/cancellation must not leave inference running. + if (inference) await call("opencode_session_abort", { id: sessionId }); + await call("opencode_session_delete", { id: sessionId }); } - - // Finally, delete temp session - await call("opencode_session_delete", { id: sessionId, directory: DIRECTORY }, { allowError: true }); - } - - // --- File tools --- - await call("opencode_file_list", { path: ".", directory: DIRECTORY }, { allowError: true }); - await call("opencode_file_status", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_file_read", { path: "package.json", directory: DIRECTORY }, { allowError: true }); - await call("opencode_find_text", { pattern: "opencode", directory: DIRECTORY }, { allowError: true }); - await call("opencode_find_file", { query: "package.json", directory: DIRECTORY }, { allowError: true }); - await call("opencode_find_symbol", { query: "register", directory: DIRECTORY }, { allowError: true }); - - // --- Misc / experimental --- - await call("opencode_agent_list", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_command_list", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_lsp_status", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_formatter_status", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_mcp_status", { directory: DIRECTORY }, { allowError: true }); - await call("opencode_tool_ids", { directory: DIRECTORY }, { allowError: true }); - - // tool_list needs provider+model; try using testModel. - if (testModel) { - await call( - "opencode_tool_list", - { provider: testModel.providerId, model: testModel.modelId, directory: DIRECTORY }, - { allowError: true }, - ); - } else { - record("opencode_tool_list", "SKIP", "No test model available"); - } - - await call( - "opencode_log", - { service: "mcp-smoke", level: "info", message: "smoke test" , directory: DIRECTORY }, - { allowError: true }, - ); - - await call( - "opencode_events_poll", - { durationMs: 1000, maxEvents: 5, directory: DIRECTORY }, - { allowError: true }, - ); - - // Skip potentially destructive or environment-dependent tools by design. - const skipped = [ - "opencode_config_update", - "opencode_instance_dispose", - "opencode_shell_execute", - "opencode_provider_oauth_authorize", - "opencode_provider_oauth_callback", - "opencode_auth_set", - "opencode_mcp_add", - "opencode_session_init", - "opencode_session_summarize", - // TUI tools require a running TUI; skip to avoid side effects. - "opencode_tui_append_prompt", - "opencode_tui_submit_prompt", - "opencode_tui_clear_prompt", - "opencode_tui_execute_command", - "opencode_tui_show_toast", - "opencode_tui_open_help", - "opencode_tui_open_sessions", - "opencode_tui_open_models", - "opencode_tui_open_themes", - ]; - for (const name of skipped) { - if (toolNames.has(name)) { - skippedTools.add(name); - record(`skipped:${name}`, "SKIP", "Intentionally skipped"); - } - } - - // If there are tools we didn't call (and didn't intentionally skip), report them. - const untested = []; - for (const name of toolNames) { - if (!calledTools.has(name) && !skippedTools.has(name)) { - untested.push(name); + for (const tool of listed) { + if (!called.has(tool.name)) { + const reason = ["opencode_run", "opencode_fire", "opencode_wait"].includes(tool.name) + ? (inference ? "Prerequisite failed; inference was not dispatched" : "Inference is disabled; use --inference with an explicit provider and model") + : "Outside the disposable fixture checks; requires separate capability-specific validation"; + record(tool.name, "SKIP", reason); + } } + try { await client.close(); } catch { record("client/close", "FAIL", "Could not close the MCP connection"); } } - if (untested.length > 0) { - record( - "tools:untested", - "FAIL", - `Untested tool(s): ${untested.sort().join(", ")}`, - ); - } - - // Close. - await client.close(); - - // Print report. - const counts = results.reduce( - (acc, r) => { - acc[r.status] = (acc[r.status] ?? 0) + 1; - return acc; - }, - {}, - ); - - console.log("\nReport:"); - console.log(`- PASS: ${counts.PASS ?? 0}`); - console.log(`- FAIL: ${counts.FAIL ?? 0}`); - console.log(`- SKIP: ${counts.SKIP ?? 0}`); + return { results, exitCode: results.some((r) => r.status === "FAIL") ? 1 : 0 }; +} - const failures = results.filter((r) => r.status === "FAIL"); - if (failures.length > 0) { - console.log("\nFailures:"); - for (const f of failures) { - console.log(`- ${f.name}: ${f.detail}`); - } - process.exitCode = 2; +export async function main(argv = process.argv.slice(2), env = process.env) { + const options = smokeOptions(argv, env); + const fixture = await createFixture(); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [resolve("dist/index.js")], + cwd: process.cwd(), stderr: "pipe", + env: { ...smokeEnvironment(options, env), OPENCODE_TASK_STORE: join(fixture.directory, ".mcp-jobs") }, + }); + // Drain diagnostics without printing potentially sensitive server messages. + transport.stderr?.on("data", () => {}); + const client = new Client({ name: "opencode-mcp-smoke", version: "1.0.0" }); + try { + await client.connect(transport); + const report = await runSmoke({ client, directory: fixture.directory, ...options }); + for (const item of report.results) console.log(`${item.status} ${item.name}${item.detail ? `: ${item.detail}` : ""}`); + return report.exitCode; + } finally { + await client.close().catch(() => {}); + await fixture.cleanup(); } } -main().catch((e) => { - console.error("Smoke test crashed:", e); - process.exit(1); -}); +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().then((code) => { process.exitCode = code; }).catch(() => { + console.error("Smoke checks could not start. Check the local server, credentials, Git, CLI options, and build output."); + process.exitCode = 1; + }); +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..3480ab7 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,72 @@ +import { McpServer, type ToolProfile } from "./mcp-server.js"; +import { OpenCodeClient } from "./client.js"; +import { JobService } from "./jobs.js"; +import { registerInputTools } from "./tools/input.js"; +import { TASKS_EXTENSION } from "./task-transport.js"; +// Tool groups +import { registerGlobalTools } from "./tools/global.js"; +import { registerConfigTools } from "./tools/config.js"; +import { registerProjectTools } from "./tools/project.js"; +import { registerSessionTools } from "./tools/session.js"; +import { registerMessageTools } from "./tools/message.js"; +import { registerFileTools } from "./tools/file.js"; +import { registerProviderTools } from "./tools/provider.js"; +import { registerMiscTools } from "./tools/misc.js"; +import { registerWorkflowTools } from "./tools/workflow.js"; +import { registerTuiTools } from "./tools/tui.js"; +import { registerEventTools } from "./tools/events.js"; + +// Resources and prompts +import { registerResources } from "./resources.js"; +import { registerPrompts } from "./prompts.js"; + +/** Build registrations without connecting or performing network/filesystem I/O. */ +export function createServer(client: OpenCodeClient, jobs: JobService, profile: ToolProfile = "full") { + const server = new McpServer({ name: "opencode-mcp", version: "3.0.0", + description: "Delegate coding work to OpenCode with durable jobs, explicit input, and project-scoped tools." }, { + capabilities: { extensions: { [TASKS_EXTENSION]: {} } }, + cacheHints: { "tools/list": { ttlMs: 60000, cacheScope: "private" }, "prompts/list": { ttlMs: 60000, cacheScope: "private" }, + "resources/list": { ttlMs: 60000, cacheScope: "private" }, "resources/templates/list": { ttlMs: 60000, cacheScope: "private" }, + "resources/read": { ttlMs: 0, cacheScope: "private" } }, + instructions: [ + "Start with opencode_setup. Discover configured providers/models using opencode_provider_list and opencode_provider_models; pass their IDs or configure OPENCODE_DEFAULT_PROVIDER/MODEL.", + "Use opencode_ask for a short task and opencode_reply to continue a session. Model output may be constrained with format: {type: 'json_schema', schema: {...}}.", + "Use opencode_run for long work. July 2026 clients advertising the Tasks extension receive a native task handle; poll tasks/get, supply explicit input via tasks/update, and abort via tasks/cancel.", + "Other clients receive an observed result from opencode_run. opencode_fire returns immediately. Keep jobId, sessionId, and messageId to resume observation with opencode_job_get, opencode_check, or opencode_wait.", + "Observation timeouts and disconnects do not cancel OpenCode work. An unknown submission outcome must be checked before any retry. Local job records expire after 24 hours; they do not keep a stopped OpenCode process alive.", + "If status is input_required, use opencode_job_input for forms/manual responses, or question_list/question_reply/question_reject and permission_list/session_permission. Approve only the operations the user authorized.", + "opencode_job_cancel explicitly aborts a job's session. Review changes with opencode_review_changes and messages with opencode_conversation.", + "directory is an absolute path on the OpenCode host; omitted directory uses that server's project. Project resource templates require an encoded absolute directory.", + "Tool annotations describe effects conservatively; mutating tools can change files, sessions, configuration, or credentials. The essential profile exposes common workflows; full adds low-level APIs and TUI controls.", + "Catalog cache hints do not imply live resource subscriptions or task progress notifications. Poll jobs/tasks for updates.", + ].join("\n"), + }); + server.profile = profile; +// ── Low-level API tools ───────────────────────────────────────────── +registerGlobalTools(server, client); +registerConfigTools(server, client); +registerProjectTools(server, client); +registerSessionTools(server, client); +registerMessageTools(server, client); +registerFileTools(server, client); +registerProviderTools(server, client); +registerMiscTools(server, client); + +// ── High-level workflow tools ─────────────────────────────────────── +registerWorkflowTools(server, client, jobs); + +// ── TUI control ───────────────────────────────────────────────────── +registerTuiTools(server, client); + +// ── Event streaming ───────────────────────────────────────────────── +registerEventTools(server, client); + +// ── Resources ─────────────────────────────────────────────────────── +registerResources(server, client); + +// ── Prompts ───────────────────────────────────────────────────────── +registerPrompts(server); + + registerInputTools(server, client, jobs); + return server; +} diff --git a/src/async.ts b/src/async.ts new file mode 100644 index 0000000..3ad17f8 --- /dev/null +++ b/src/async.ts @@ -0,0 +1,88 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +/** One operation budget shared by requests, retries and observation sleeps. */ +export interface RequestOptions { + signal?: AbortSignal; + /** Absolute deadline, in milliseconds since the Unix epoch. */ + deadline?: number; + /** Maximum total duration in milliseconds, including retries. */ + timeout?: number; +} + +const requestOptionsStorage = new AsyncLocalStorage(); + +/** Propagate the MCP caller's cancellation context through ordinary handlers. */ +export function withRequestOptions(options: RequestOptions, callback: () => T): T { + return requestOptionsStorage.run(options, callback); +} + +export function currentRequestOptions(): RequestOptions { + return requestOptionsStorage.getStore() ?? {}; +} + +export function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new DOMException("Operation aborted", "AbortError"); +} + +export function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw abortReason(signal); +} + +/** Race even libraries that swallow AbortError or do not honor the signal. */ +export function withAbort(work: PromiseLike, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abort = () => reject(abortReason(signal)); + Promise.resolve(work).then( + (value) => { signal.removeEventListener("abort", abort); resolve(value); }, + (error) => { signal.removeEventListener("abort", abort); reject(error); }, + ); + if (signal.aborted) abort(); + else signal.addEventListener("abort", abort, { once: true }); + }); +} + +export function createRequestContext(options: RequestOptions = {}, defaultTimeout = 120_000) { + const inherited = currentRequestOptions(); + options = { + signal: options.signal ?? inherited.signal, + deadline: options.deadline ?? inherited.deadline, + timeout: options.timeout ?? inherited.timeout, + }; + const timeout = options.timeout ?? defaultTimeout; + if (!Number.isFinite(timeout) || timeout <= 0 || timeout > 2_147_483_647) { + throw new RangeError("timeout must be a positive finite duration no greater than 2147483647ms"); + } + if (options.deadline !== undefined && !Number.isFinite(options.deadline)) { + throw new RangeError("deadline must be a finite Unix timestamp in milliseconds"); + } + const deadline = Math.min(options.deadline ?? Infinity, Date.now() + timeout); + const controller = new AbortController(); + const abort = () => controller.abort(options.signal?.reason); + const expire = () => controller.abort(new DOMException("Operation deadline exceeded", "TimeoutError")); + if (options.signal?.aborted) abort(); + else options.signal?.addEventListener("abort", abort, { once: true }); + const remaining = deadline - Date.now(); + const timer = remaining > 0 ? setTimeout(expire, remaining) : undefined; + if (remaining <= 0) expire(); + return { + signal: controller.signal, + deadline, + abort: (reason?: unknown) => controller.abort(reason), + dispose() { + if (timer !== undefined) clearTimeout(timer); + options.signal?.removeEventListener("abort", abort); + }, + }; +} + +export function abortableSleep(milliseconds: number, signal?: AbortSignal): Promise { + throwIfAborted(signal); + return new Promise((resolve, reject) => { + const cleanup = () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); }; + const abort = () => { cleanup(); reject(abortReason(signal!)); }; + const timer = setTimeout(() => { cleanup(); resolve(); }, milliseconds); + signal?.addEventListener("abort", abort, { once: true }); + }); +} diff --git a/src/client.ts b/src/client.ts index 91e0cff..29104e2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,8 @@ import { createOpencodeClient, OpencodeClient as NativeClient } from "@opencode-ai/sdk"; import { normalizeDirectory } from "./helpers.js"; import { ensureServer, isServerRunning } from "./server-manager.js"; +import { createRequestContext, abortableSleep, throwIfAborted, withAbort, type RequestOptions } from "./async.js"; +export type { RequestOptions } from "./async.js"; export interface OpenCodeClientOptions { baseUrl: string; @@ -39,6 +41,15 @@ export class OpenCodeError extends Error { } } +/** The server may have accepted a write whose response was lost. */ +export class OpenCodeSubmissionError extends Error { + readonly submissionOutcome = "unknown" as const; + constructor(public readonly method: string, public readonly path: string, cause: unknown) { + super(`${method} ${path}: submission outcome unknown; the server may have accepted this operation. Check its session before retrying. ${cause instanceof Error ? cause.message : String(cause)}`, { cause }); + this.name = "OpenCodeSubmissionError"; + } +} + const MAX_RETRIES = 2; const BASE_DELAY_MS = 500; const MAX_RECONNECT_ATTEMPTS = 3; @@ -99,220 +110,177 @@ export class OpenCodeClient { private async request( method: string, path: string, - opts?: { - query?: Record; - body?: unknown; - timeout?: number; - directory?: string; - }, + opts: RequestOptions & { query?: Record; body?: unknown; directory?: string } = {}, ): Promise { + // Validate before submission. A malformed local argument cannot be an + // ambiguous remote mutation and must never trigger retries. + const normalized = normalizeDirectory(opts.directory); + const headers: Record = normalized ? { "x-opencode-directory": normalized } : {}; + const context = createRequestContext(opts); + const readOnly = method === "GET"; let lastError: Error | undefined; - - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - if (attempt > 0) { - const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1); - await new Promise((r) => setTimeout(r, delay)); - } - - try { - const headers: Record = {}; - const normalized = normalizeDirectory(opts?.directory); - if (normalized) { - // The OpenCode server treats this header as a literal absolute - // filesystem path. Do NOT URI-encode it — `encodeURIComponent` - // would turn `/tmp/proj` into `%2Ftmp%2Fproj` and break project - // scoping for every tool that passes a `directory`. - headers["x-opencode-directory"] = normalized; - } - - const apiClient = (this.api as any)._client; - const controller = opts?.timeout ? new AbortController() : undefined; - const timer = controller && opts?.timeout ? setTimeout(() => controller.abort(), opts.timeout) : undefined; - const signal = controller?.signal; - - let res; + let submitted = false; + try { + for (let attempt = 0; attempt <= (readOnly ? MAX_RETRIES : 0); attempt++) { + throwIfAborted(context.signal); + if (attempt > 0) await abortableSleep(BASE_DELAY_MS * Math.pow(2, attempt - 1), context.signal); try { - switch (method.toUpperCase()) { - case 'GET': - res = await apiClient.get({ url: path, query: opts?.query, headers, signal }); - break; - case 'POST': - res = await apiClient.post({ url: path, query: opts?.query, body: opts?.body as any, headers, signal }); - break; - case 'PATCH': - res = await apiClient.patch({ url: path, query: opts?.query, body: opts?.body as any, headers, signal }); - break; - case 'PUT': - res = await apiClient.put({ url: path, query: opts?.query, body: opts?.body as any, headers, signal }); - break; - case 'DELETE': - res = await apiClient.delete({ url: path, query: opts?.query, headers, signal }); - break; - default: - throw new Error(`Unsupported method ${method}`); + const apiClient = (this.api as any)._client; + submitted = true; + const res = await withAbort(apiClient[method.toLowerCase()]({ + url: path, query: opts.query, + ...(method !== "GET" && method !== "DELETE" ? { body: opts.body } : {}), + headers, signal: context.signal, + }), context.signal) as any; + // The SDK may return an error object after fetch was aborted. + throwIfAborted(context.signal); + if (res.error || (res.response && !res.response.ok && res.response.status >= 400)) { + const status = res.response?.status || 500; + const bodyStr = typeof res.error === "string" ? res.error : JSON.stringify(res.error ?? {}); + throw new OpenCodeError(`${method} ${path} failed (${status}): ${bodyStr}`, status, method, path, bodyStr); } - } finally { - if (timer) clearTimeout(timer); - } - - if (res.error) { - const status = res.response?.status || 500; - const bodyStr = typeof res.error === 'string' ? res.error : JSON.stringify(res.error); - const err = new OpenCodeError(`${method} ${path} failed (${status}): ${bodyStr}`, status, method, path, bodyStr); - if (err.isTransient && attempt < MAX_RETRIES) { - lastError = err; - continue; + this.reconnectAttempts = 0; + return res.data as T; + } catch (e) { + // Writes may already have been accepted. No generic HTTP status, + // disconnect or timeout proves it is safe to send them a second time. + if (!readOnly) { + if (e instanceof OpenCodeError) throw e; + throw new OpenCodeSubmissionError(method, path, e); } - throw err; + throwIfAborted(context.signal); + lastError = e instanceof Error ? e : new Error(String(e)); + if (e instanceof OpenCodeError && !e.isTransient) throw e; + if (!(e instanceof OpenCodeError) && !isConnectionError(lastError)) throw e; } - - // Successful round-trip — replenish the reconnect budget so a - // long-lived client doesn't permanently exhaust auto-healing. - this.reconnectAttempts = 0; - return res.data as T; - } catch (e) { - if (e instanceof OpenCodeError) throw e; - lastError = e as Error; - if (attempt >= MAX_RETRIES) break; } - } - if ( - this.autoServe && - this.reconnectAttempts < MAX_RECONNECT_ATTEMPTS && - lastError && - isConnectionError(lastError) - ) { - this.reconnectAttempts++; - console.error( - `Connection failed (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}), attempting server reconnection...`, - ); - try { - const status = await isServerRunning( - this.baseUrl, - this.username, - this.password, - ); - if (!status.healthy) { - const ensured = await ensureServer({ - baseUrl: this.baseUrl, - autoServe: true, - username: this.username, - password: this.password, - }); - // If `ensureServer()` chose a different URL (e.g. the managed - // SDK server bound to an alternative port), rebuild the SDK - // client so the retry targets the live endpoint instead of the - // dead one we were originally probing. - if (ensured.url) { - const normalized = ensured.url.replace(/\/$/, ""); - if (normalized !== this.baseUrl) { - this.baseUrl = normalized; - this.api = this.buildSdkClient(); + if (this.autoServe && this.reconnectAttempts < MAX_RECONNECT_ATTEMPTS && lastError && isConnectionError(lastError)) { + this.reconnectAttempts++; + console.error(`Connection failed (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}), attempting server reconnection...`); + try { + const status = await withAbort(isServerRunning(this.baseUrl, this.username, this.password), context.signal); + if (!status.healthy) { + throwIfAborted(context.signal); + const ensured = await withAbort(ensureServer({ + baseUrl: this.baseUrl, autoServe: true, username: this.username, password: this.password, + }), context.signal); + if (ensured.url) { + const nextUrl = ensured.url.replace(/\/$/, ""); + if (nextUrl !== this.baseUrl) { + this.baseUrl = nextUrl; + this.api = this.buildSdkClient(); + } } } + return await this.request(method, path, { ...opts, signal: context.signal, deadline: context.deadline }); + } catch (e) { + throwIfAborted(context.signal); + console.error(`Server reconnection failed: ${e instanceof Error ? e.message : String(e)}`); } - return this.request(method, path, opts); - } catch (reconnectErr) { - console.error(`Server reconnection failed: ${reconnectErr instanceof Error ? reconnectErr.message : String(reconnectErr)}`); } + throw lastError ?? new Error(`${method} ${path} failed after retries`); + } catch (error) { + // Preflight cancellation has no submission ambiguity. + if (!readOnly && submitted && !(error instanceof OpenCodeError) && !(error instanceof OpenCodeSubmissionError)) { + throw new OpenCodeSubmissionError(method, path, error); + } + throw error; + } finally { + context.dispose(); } - - throw lastError ?? new Error(`${method} ${path} failed after retries`); } - async get(path: string, query?: Record, directory?: string): Promise { - return this.request("GET", path, { query, directory }); + async get(path: string, query?: Record, directory?: string, options?: RequestOptions): Promise { + return this.request("GET", path, { ...options, query, directory }); } - async post(path: string, body?: unknown, opts?: { timeout?: number; directory?: string }): Promise { - return this.request("POST", path, { body, timeout: opts?.timeout, directory: opts?.directory }); + async post(path: string, body?: unknown, opts?: RequestOptions & { directory?: string }): Promise { + return this.request("POST", path, { ...opts, body }); } - async patch(path: string, body?: unknown, directory?: string): Promise { - return this.request("PATCH", path, { body, directory }); + async patch(path: string, body?: unknown, directory?: string, options?: RequestOptions): Promise { + return this.request("PATCH", path, { ...options, body, directory }); } - async put(path: string, body?: unknown, directory?: string): Promise { - return this.request("PUT", path, { body, directory }); + async put(path: string, body?: unknown, directory?: string, options?: RequestOptions): Promise { + return this.request("PUT", path, { ...options, body, directory }); } - async delete(path: string, query?: Record, directory?: string): Promise { - return this.request("DELETE", path, { query, directory }); + async delete(path: string, query?: Record, directory?: string, options?: RequestOptions): Promise { + return this.request("DELETE", path, { ...options, query, directory }); } - async *subscribeSSE(path: string, opts?: { signal?: AbortSignal }): AsyncGenerator<{ event: string; data: string }, void, undefined> { + async *subscribeSSE(path: string, opts: RequestOptions & { directory?: string } = {}): AsyncGenerator<{ event: string; data: string }, void, undefined> { const url = new URL(path, this.baseUrl).toString(); - const headers: Record = { - Accept: "text/event-stream", - "Cache-Control": "no-cache", - }; + const headers: Record = { Accept: "text/event-stream", "Cache-Control": "no-cache" }; + const normalized = normalizeDirectory(opts.directory); + if (normalized) headers["x-opencode-directory"] = normalized; const authHeader = buildBasicAuthHeader(this.username, this.password); - if (authHeader) { - headers["Authorization"] = authHeader; - } - - const res = await fetch(url, { - method: "GET", - headers, - signal: opts?.signal, - }); - - if (!res.ok) { - const text = await res.text(); - throw new OpenCodeError(`SSE ${path} failed (${res.status}): ${text}`, res.status, "GET", path, text); - } - - if (!res.body) throw new Error("No response body for SSE stream"); - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let currentEvent = ""; - let currentData = ""; - - const abortHandler = () => { try { void reader.cancel().catch(() => {}); } catch {} }; - if (opts?.signal) { - if (opts.signal.aborted) abortHandler(); - else opts.signal.addEventListener("abort", abortHandler, { once: true }); - } - + if (authHeader) headers["Authorization"] = authHeader; + const context = createRequestContext(opts, 30_000); + let reader: ReadableStreamDefaultReader | undefined; + const cancelReader = () => { void reader?.cancel().catch(() => {}); }; try { + throwIfAborted(context.signal); + const res = await withAbort(fetch(url, { method: "GET", headers, signal: context.signal }), context.signal); + if (!res.ok) { + const text = await withAbort(res.text(), context.signal); + throw new OpenCodeError(`SSE ${path} failed (${res.status}): ${text}`, res.status, "GET", path, text); + } + if (!res.body) throw new Error("No response body for SSE stream"); + reader = res.body.getReader(); + context.signal.addEventListener("abort", cancelReader, { once: true }); + const decoder = new TextDecoder(); + let buffer = ""; + let event = ""; + let data: string[] = []; while (true) { - if (opts?.signal?.aborted) break; - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const rawLines = buffer.split("\n"); - buffer = rawLines.pop() ?? ""; - - for (const rawLine of rawLines) { - // SSE per RFC 8895 allows CRLF line endings. Splitting on - // "\n" leaves a trailing "\r" on each line, which breaks - // both event-name comparisons (event becomes "message\r") - // and the blank-line dispatcher (a "blank" CRLF line is - // "\r", not ""). Strip the trailing CR before processing. - const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; - - if (line.startsWith("event:")) { - currentEvent = line.slice(6).trim(); - } else if (line.startsWith("data:")) { - currentData = line.slice(5).trim(); - } else if (line === "") { - if (currentData) { - yield { event: currentEvent || "message", data: currentData }; - currentEvent = ""; - currentData = ""; - } + throwIfAborted(context.signal); + const { done, value } = await withAbort(reader.read(), context.signal); + throwIfAborted(context.signal); + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }); + // SSE accepts LF, CRLF and lone CR. Preserve CR across chunk boundaries + // so a split CRLF is consumed as one separator. + let match: RegExpExecArray | null; + while ((match = /\r\n|\r|\n/.exec(buffer))) { + if (!done && match[0] === "\r" && match.index === buffer.length - 1) break; + const line = buffer.slice(0, match.index); + buffer = buffer.slice(match.index + match[0].length); + if (data.reduce((sum, item) => sum + item.length, 0) + line.length > 1_000_000) { + throw new Error("SSE event exceeds the 1MB limit"); + } + if (line === "") { + if (data.length > 0) yield { event: event || "message", data: data.join("\n") }; + event = ""; + data = []; + } else if (!line.startsWith(":")) { + const separator = line.indexOf(":"); + const field = separator < 0 ? line : line.slice(0, separator); + let content = separator < 0 ? "" : line.slice(separator + 1); + if (content.startsWith(" ")) content = content.slice(1); + if (field === "event") event = content; + if (field === "data") data.push(content); } } + // Ignore an unfinished event at EOF, as required by SSE framing. + if (done) break; + if (buffer.length + data.reduce((sum, line) => sum + line.length, 0) > 1_000_000) { + throw new Error("SSE event exceeds the 1MB limit"); + } } } finally { - if (opts?.signal) { - try { opts.signal.removeEventListener("abort", abortHandler); } catch {} + context.signal.removeEventListener("abort", cancelReader); + context.abort(); + // Cancel the underlying stream on maxEvents/early return as well as + // errors. Releasing the reader lock alone leaves the HTTP stream open. + if (reader) { + // Cancellation itself may be asynchronous in a custom stream; do not + // let cleanup extend the caller's deadline indefinitely. + try { void reader.cancel().catch(() => {}); } catch { /* Preserve the stream error. */ } + reader.releaseLock(); } - reader.releaseLock(); + context.dispose(); } } } diff --git a/src/helpers.ts b/src/helpers.ts index c7aeebe..46e1458 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -34,6 +34,16 @@ export const directoryParam = z "If omitted, the OpenCode server uses its own working directory.", ); +/** OpenCode SDK OutputFormat, accepted by synchronous and async prompts. */ +export const outputFormatParam = z.discriminatedUnion("type", [ + z.object({ type: z.literal("text") }), + z.object({ + type: z.literal("json_schema"), + schema: z.record(z.string(), z.unknown()), + retryCount: z.number().int().nonnegative().optional(), + }), +]).optional().describe("Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool."); + // ── Default Provider/Model ──────────────────────────────────────────── /** @@ -115,6 +125,9 @@ export function normalizeDirectory(directory?: string): string | undefined { */ export function formatMessageResponse(response: unknown): string { const r = response as any; + // StructuredOutput is a tool call whose generic acknowledgement is not the + // answer. Prefer the validated value over tool receipts and token metadata. + if (r?.info?.structured !== undefined) return safeStringify(r.info.structured); const sections: string[] = []; // Omit verbose message header for cleaner output; the caller (opencode_ask etc.) @@ -209,7 +222,9 @@ export function formatMessageList( let summary = `--- Message ${i + 1} [${role}] (${id}) ---\n`; - if (textParts) { + if (msg?.info?.structured !== undefined) { + summary += safeStringify(msg.info.structured); + } else if (textParts) { summary += textParts; if (toolParts.length > 0) { summary += `\n[${toolParts.length} tool call(s)]`; @@ -325,18 +340,33 @@ export function formatSessionList( } /** - * Generic safe JSON stringify with truncation for very large responses. + * Serialize JSON, returning a valid JSON preview envelope for large values. + * The preview is deliberately a string: it must never be mistaken for complete data. */ export function safeStringify( value: unknown, maxLength: number = 50000, ): string { - const json = JSON.stringify(value, null, 2); + if (!Number.isInteger(maxLength) || maxLength < 32) { + throw new Error("JSON output budget must be an integer of at least 32 characters."); + } + const json = JSON.stringify(value ?? null, null, 2); if (json.length <= maxLength) return json; - return ( - json.slice(0, maxLength) + - `\n\n... [truncated, ${json.length - maxLength} more characters]` - ); + const envelope = (length: number) => JSON.stringify({ + truncated: true, + originalLength: json.length, + omittedCharacters: json.length - length, + preview: json.slice(0, length), + }); + if (envelope(0).length > maxLength) return JSON.stringify({ truncated: true }); + let low = 0; + let high = Math.min(json.length, maxLength); + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if (envelope(middle).length <= maxLength) low = middle; + else high = middle - 1; + } + return envelope(low); } /** @@ -372,22 +402,19 @@ export function analyzeMessageResponse(response: unknown): { (p: any) => p.error || (p.type === "tool" && p.state?.status === "error") || - (p.type === "tool-result" && p.error) || - (typeof p.text === "string" && /\b(error|unauthorized|forbidden|invalid.?key)\b/i.test(p.text)), + (p.type === "tool-result" && p.error), ); - if (errorParts.length > 0) { + if (r?.info?.error || r?.error || errorParts.length > 0) { const firstError = - errorParts[0].error ?? - errorParts[0].state?.error ?? - errorParts[0].text ?? + r?.info?.error ?? r?.error ?? + errorParts[0]?.error ?? + errorParts[0]?.state?.error ?? JSON.stringify(errorParts[0]); return { isEmpty: false, hasError: true, warning: - `The response contains an error: ${typeof firstError === "string" ? firstError : JSON.stringify(firstError)}. ` + - "This may indicate an authentication issue. " + - "Use `opencode_auth_set` to verify your API key.", + `The response contains an error: ${typeof firstError === "string" ? firstError : JSON.stringify(firstError)}.`, }; } @@ -398,6 +425,7 @@ export function analyzeMessageResponse(response: unknown): { .join(""); const hasToolActivity = parts.some((p: any) => ["tool", "tool-invocation", "tool-result"].includes(p.type)); + if (r?.info?.structured !== undefined) return { isEmpty: false, hasError: false, warning: null }; if (parts.length === 0 || (textContent === "" && !hasToolActivity)) { return { isEmpty: true, @@ -436,8 +464,8 @@ function redactUrlSecrets(url: string): string { let changed = false; const sensitiveParamPattern = /(?:key|token|secret|password|credential|auth)/i; for (const [name, val] of parsed.searchParams.entries()) { - if ((sensitiveParamPattern.test(name) && val.length > 8) || looksLikeSecret(val)) { - parsed.searchParams.set(name, val.slice(0, 4) + "***REDACTED***"); + if ((sensitiveParamPattern.test(name) && val.length > 0) || looksLikeSecret(val)) { + parsed.searchParams.set(name, (val.length > 8 ? val.slice(0, 4) : "") + "***REDACTED***"); changed = true; } } @@ -477,9 +505,9 @@ export function redactSecrets(value: unknown): unknown { const sensitiveKeyPattern = /(?:key|token|secret|password|credential|api_key|apikey|auth)/i; for (const [k, v] of Object.entries(value as Record)) { if (typeof v === "string") { - if (v.length > 8 && sensitiveKeyPattern.test(k)) { + if (v.length > 0 && sensitiveKeyPattern.test(k)) { // Layer 1: key-name match - result[k] = v.slice(0, 4) + "***REDACTED***"; + result[k] = (v.length > 8 ? v.slice(0, 4) : "") + "***REDACTED***"; } else if (looksLikeSecret(v)) { // Layer 2: value looks like a secret result[k] = v.slice(0, 4) + "***REDACTED***"; @@ -550,10 +578,11 @@ export function resolveSessionStatus(raw: unknown): string { /** * Standard tool response builder. */ -export function toolResult(text: string, isError = false) { +export function toolResult(text: string, isError = false, structuredContent?: Record) { return { content: [{ type: "text" as const, text }], ...(isError ? { isError: true } : {}), + ...(structuredContent ? { structuredContent } : {}), }; } @@ -586,7 +615,7 @@ function diagnoseError(msg: string): string { tips.push("- List active sessions with `opencode_sessions_overview`"); } else if (lower.includes("rate limit") || lower.includes("429")) { tips.push("- Wait a moment and retry, or switch provider"); - tips.push("- Try a free model: `opencode_ask` with providerID `opencode`, modelID `minimax-m2.1-free`"); + tips.push("- Use `opencode_provider_list` and `opencode_provider_models` to find an available alternative"); } else if (lower.includes("econnrefused")) { tips.push("- The OpenCode server is not accepting connections"); tips.push("- Is `opencode serve` running? Check with `opencode_setup`"); @@ -612,5 +641,6 @@ function diagnoseError(msg: string): string { } export function toolJson(value: unknown) { - return toolResult(safeStringify(value)); + const text = safeStringify(value); + return toolResult(text, false, { data: JSON.parse(text) }); } diff --git a/src/index.ts b/src/index.ts index 6b10f88..9b23128 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,225 +1,31 @@ #!/usr/bin/env node - -/** - * OpenCode MCP Server - * - * An MCP (Model Context Protocol) server that wraps the OpenCode AI headless - * server HTTP API. This allows any MCP client to interact with a running - * OpenCode instance — manage sessions, send prompts, search files, configure - * providers, and more. - * - * Features: - * - 80 tools covering the entire OpenCode API surface - * - High-level workflow tools (opencode_ask, opencode_reply, etc.) - * - Smart response formatting for LLM-friendly output - * - MCP Resources for browseable project data - * - MCP Prompts for guided workflows - * - SSE event polling - * - TUI control tools - * - Retry logic with exponential backoff - * - Auto-detection and auto-start of the OpenCode server - * - * Environment variables: - * OPENCODE_BASE_URL - Base URL of the OpenCode server (default: http://127.0.0.1:4096) - * OPENCODE_SERVER_USERNAME - Username for HTTP basic auth (default: opencode) - * OPENCODE_SERVER_PASSWORD - Password for HTTP basic auth (optional) - * OPENCODE_AUTO_SERVE - Set to "true" to enable auto-start (default: false) - * OPENCODE_DEFAULT_PROVIDER - Default provider ID when not specified per-tool (optional) - * OPENCODE_DEFAULT_MODEL - Default model ID when not specified per-tool (optional) - */ - -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createHash } from "node:crypto"; +import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio"; import { OpenCodeClient } from "./client.js"; +import { JobService } from "./jobs.js"; +import { TaskTransport } from "./task-transport.js"; +import { createServer } from "./app.js"; import { ensureServer, registerShutdownHandlers } from "./server-manager.js"; import { setModelDefaults } from "./helpers.js"; -// Tool groups -import { registerGlobalTools } from "./tools/global.js"; -import { registerConfigTools } from "./tools/config.js"; -import { registerProjectTools } from "./tools/project.js"; -import { registerSessionTools } from "./tools/session.js"; -import { registerMessageTools } from "./tools/message.js"; -import { registerFileTools } from "./tools/file.js"; -import { registerProviderTools } from "./tools/provider.js"; -import { registerMiscTools } from "./tools/misc.js"; -import { registerWorkflowTools } from "./tools/workflow.js"; -import { registerTuiTools } from "./tools/tui.js"; -import { registerEventTools } from "./tools/events.js"; - -// Resources and prompts -import { registerResources } from "./resources.js"; -import { registerPrompts } from "./prompts.js"; - -const baseUrl = - process.env.OPENCODE_BASE_URL ?? "http://127.0.0.1:4096"; -const username = process.env.OPENCODE_SERVER_USERNAME; -const password = process.env.OPENCODE_SERVER_PASSWORD; -const autoServe = process.env.OPENCODE_AUTO_SERVE === "true"; -const defaultProvider = process.env.OPENCODE_DEFAULT_PROVIDER; -const defaultModel = process.env.OPENCODE_DEFAULT_MODEL; - -// Set global model defaults from env vars (used by applyModelDefaults() in tools) -setModelDefaults(defaultProvider, defaultModel); - -// Use env-var defaults in instruction examples; fall back to generic placeholders -const exProvider = defaultProvider || ""; -const exModel = defaultModel || ""; - -const client = new OpenCodeClient({ baseUrl, username, password, autoServe }); - -const server = new McpServer( - { - name: "opencode-mcp", - version: "2.0.1", - description: - "MCP server wrapping the OpenCode AI coding agent. " + - "Delegates complex coding tasks (build apps, refactor, debug) to an autonomous AI agent. " + - "Start with opencode_setup, then use opencode_ask for simple tasks, opencode_run for complex tasks, or opencode_fire for long-running background work.", - }, - { - instructions: [ - "# OpenCode MCP — Guide for LLM Clients", - "", - "You are connected to OpenCode, an autonomous AI coding agent that can build, edit, and debug software projects.", - "This server exposes ~80 tools organized into tiers. Use high-level tools first; drop to low-level only when needed.", - "", - "## Getting Started (First Time)", - "1. Call `opencode_setup` — checks server health, shows configured providers, and suggests next steps.", - "2. Pick a provider from the **Ready to use** list returned by `opencode_setup`, then call `opencode_provider_models` to see its models.", - "3. IMPORTANT: Always pass `providerID` and `modelID` from the discovered providers when sending prompts, or you may get empty responses. Do NOT assume any specific provider is available — always discover first.", - "", - "## Tool Tiers (prefer higher tiers)", - "", - "### Tier 1 — Essential (use these first)", - "- `opencode_setup` — first-time onboarding, health check (read-only)", - "- `opencode_ask` — one-shot question/task, creates session + gets response in one call. Simplest way to use OpenCode.", - "- `opencode_reply` — continue a conversation in an existing session", - "- `opencode_context` — get project info (path, git branch, config, agents) (read-only)", - "", - "### Tier 2 — Async Tasks (for complex/long work)", - "- `opencode_run` — RECOMMENDED: send a task and wait for completion in one call. Creates session, sends prompt, polls until done. Best for tasks under 10 minutes.", - "- `opencode_fire` — fire-and-forget: send a task and return immediately. Use `opencode_check` to monitor progress. Best for long tasks (10+ min).", - "- `opencode_check` — cheap progress report: status, todos, file counts. Use to monitor sessions from `opencode_fire`. (read-only)", - "- `opencode_wait` — block until a session finishes processing. Use after `opencode_message_send_async`. Has timeout.", - "- `opencode_session_todo` — see the agent's internal task list for a session (read-only)", - "", - "### Tier 3 — Monitoring & Review", - "- `opencode_review_changes` — see all file diffs from a session (read-only)", - "- `opencode_conversation` — get full message history (read-only)", - "- `opencode_sessions_overview` — list all sessions with status (read-only)", - "- `opencode_provider_models` — list models for a specific provider (read-only)", - "- `opencode_status` — quick server health dashboard (read-only)", - "", - "### Tier 4 — Fine-Grained Control", - "- `opencode_session_*` — create, delete, fork, abort, share sessions", - "- `opencode_message_*` — send messages, list history, execute commands", - "- `opencode_permission_list` / `opencode_session_permission` — check and respond to permission requests", - "- `opencode_file_*` / `opencode_find_*` — search files, read content, check VCS status", - "- `opencode_provider_*` — manage providers, auth, OAuth flows", - "", - "### Tier 5 — Specialist (rarely needed)", - "- `opencode_tui_*` — control the terminal UI (only if a TUI is running)", - "- `opencode_events_poll` — poll raw SSE events", - "- `opencode_mcp_*` — manage MCP servers inside OpenCode", - "- `opencode_instance_dispose` — shut down the server (DESTRUCTIVE!)", - "", - "## Recommended Workflows", - "", - "### Quick question or small task:", - "```", - `opencode_ask({prompt: "How does auth work in this project?", providerID: "${exProvider}", modelID: "${exModel}"})`, - "```", - "", - "### Complex multi-step task (build an app, refactor code, etc.):", - "```", - "// Option A: One-call (recommended for tasks under 10 min)", - `opencode_run({prompt: "Build a React login form with validation...", providerID: "${exProvider}", modelID: "${exModel}", maxDurationSeconds: 600})`, - "", - "// Option B: Fire-and-forget (for longer tasks)", - `opencode_fire({prompt: "Build a full React app with auth, dashboard...", providerID: "${exProvider}", modelID: "${exModel}"})`, - "// ... do other work ...", - 'opencode_check({sessionId: "ses_xxx"}) // quick progress check', - 'opencode_review_changes({sessionId: "ses_xxx"}) // see changes after completion', - "```", - "", - "### Continue working on an existing session:", - "```", - `opencode_reply({sessionId: "ses_xxx", prompt: "Now add form validation", providerID: "${exProvider}", modelID: "${exModel}"})`, - "```", - "", - "## Permissions", - "OpenCode may pause a session to ask for permission (e.g. to run a shell command or access files outside the project).", - "- **Recommended for headless/automation:** Set `\"permission\": \"allow\"` in `opencode.json` to auto-approve all operations. Without this, sessions can block waiting for approval.", - "- **If a session seems stuck:** Call `opencode_permission_list` to check for pending permission requests, then respond with `opencode_session_permission`.", - "- You can also set permissions at runtime: `opencode_config_update({config: {permission: \"allow\"}})`", - "", - "## Important Notes", - "- ALWAYS specify `providerID` and `modelID` when using `opencode_ask`, `opencode_reply`, `opencode_message_send`, or `opencode_message_send_async`. Without these, the agent may return empty responses. Use providers and models discovered via `opencode_setup` — do NOT hardcode any specific provider. If `OPENCODE_DEFAULT_PROVIDER` and `OPENCODE_DEFAULT_MODEL` env vars are set, they will be used as fallbacks when you don't specify them.", - "- The `directory` parameter on every tool targets a specific project. Omit it to use the server's default project. Must be an absolute path on the OpenCode server. Relative paths are rejected; OpenCode validates existence.", - "- Tools marked with `readOnlyHint: true` in their annotations are safe and don't modify state.", - "- Tools marked with `destructiveHint: true` (`opencode_instance_dispose`, `opencode_session_delete`) permanently delete data — confirm with the user before calling.", - "- `opencode_wait` sends `notifications/message` progress updates while blocking. If it times out, it returns a progress report instead of failing.", - "- For tasks under 10 minutes, prefer `opencode_run` (one call, handles everything). For longer tasks, use `opencode_fire` + `opencode_check`.", - "- For very long tasks, use `opencode_fire` + periodically call `opencode_check` or `opencode_session_todo` to monitor progress.", - ].join("\n"), - }, -); - -// ── Low-level API tools ───────────────────────────────────────────── -registerGlobalTools(server, client); -registerConfigTools(server, client); -registerProjectTools(server, client); -registerSessionTools(server, client); -registerMessageTools(server, client); -registerFileTools(server, client); -registerProviderTools(server, client); -registerMiscTools(server, client); - -// ── High-level workflow tools ─────────────────────────────────────── -registerWorkflowTools(server, client); - -// ── TUI control ───────────────────────────────────────────────────── -registerTuiTools(server, client); - -// ── Event streaming ───────────────────────────────────────────────── -registerEventTools(server, client); - -// ── Resources ─────────────────────────────────────────────────────── -registerResources(server, client); - -// ── Prompts ───────────────────────────────────────────────────────── -registerPrompts(server); - -// ── Start ─────────────────────────────────────────────────────────── async function main() { - // Step 0: Register process lifecycle & shutdown handlers. + const baseUrl = process.env.OPENCODE_BASE_URL ?? "http://127.0.0.1:4096"; + const username = process.env.OPENCODE_SERVER_USERNAME; + const password = process.env.OPENCODE_SERVER_PASSWORD; + const autoServe = process.env.OPENCODE_AUTO_SERVE === "true"; + const profile = process.env.OPENCODE_TOOL_PROFILE ?? "full"; + if (profile !== "full" && profile !== "essential") throw new Error("OPENCODE_TOOL_PROFILE must be full or essential"); + setModelDefaults(process.env.OPENCODE_DEFAULT_PROVIDER, process.env.OPENCODE_DEFAULT_MODEL); registerShutdownHandlers(); - - // Step 1: Ensure OpenCode server is available (auto-start if needed). - try { - await ensureServer({ baseUrl, autoServe, username, password }); - } catch (err) { - // Log the error but don't prevent MCP from starting — tools will - // report connection errors individually, and the server may come - // up later. - console.error( - `Warning: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - // Step 2: Connect the MCP transport. - const transport = new StdioServerTransport(); - await server.connect(transport); - const defaultsInfo = defaultProvider && defaultModel - ? ` | defaults: ${defaultProvider}/${defaultModel}` - : ""; - console.error( - `opencode-mcp v2.0.1 started (OpenCode server at ${baseUrl}${defaultsInfo})`, - ); + const client = new OpenCodeClient({ baseUrl, username, password, autoServe }); + const scope = createHash("sha256").update(JSON.stringify([baseUrl, username ?? "opencode", password ?? ""])).digest("hex"); + const jobs = new JobService(client, { storeRoot: process.env.OPENCODE_TASK_STORE, scope }); + try { await ensureServer({ baseUrl, autoServe, username, password }); } + catch (error) { console.error(`Warning: ${error instanceof Error ? error.message : String(error)}`); } + serveStdio(() => createServer(client, jobs, profile), { + legacy: "serve", transport: new TaskTransport(new StdioServerTransport(), jobs), + onerror: error => console.error("MCP transport error:", error.message), + }); + console.error(`opencode-mcp v3.0.0 started (profile: ${profile})`); } - -main().catch((err) => { - console.error("Fatal error starting opencode-mcp:", err); - process.exit(1); -}); +main().catch(error => { console.error("Fatal error starting opencode-mcp:", error); process.exitCode = 1; }); diff --git a/src/job-contract.ts b/src/job-contract.ts new file mode 100644 index 0000000..09f3c18 --- /dev/null +++ b/src/job-contract.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; +import { directoryParam, outputFormatParam } from "./helpers.js"; + +export const dispatchShape = { + prompt: z.string().min(1).describe("Task or instruction to send to OpenCode"), + sessionId: z.string().optional().describe("Existing session to continue; omit to create one"), + title: z.string().optional().describe("Title for a new session"), + providerID: z.string().optional().describe("Provider ID from provider discovery"), + modelID: z.string().optional().describe("Model ID from provider discovery"), + variant: z.string().optional().describe("Model variant"), + agent: z.string().optional().describe("OpenCode agent name"), + directory: directoryParam, + format: outputFormatParam, +}; +export const runShape = { + ...dispatchShape, + maxDurationSeconds: z.number().positive().max(3600).optional() + .describe("Maximum observation duration in seconds (default 600); timeout does not abort the job"), +}; +export const runInputSchema = z.object(runShape); diff --git a/src/jobs.ts b/src/jobs.ts new file mode 100644 index 0000000..4457e4b --- /dev/null +++ b/src/jobs.ts @@ -0,0 +1,482 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { chmod, mkdir, open, readFile, readdir, rename, rm, rmdir } from "node:fs/promises"; +import { homedir, hostname } from "node:os"; +import { join } from "node:path"; +import { OpenCodeClient, OpenCodeError, OpenCodeSubmissionError } from "./client.js"; +import { abortableSleep, createRequestContext, throwIfAborted, withRequestOptions, type RequestOptions } from "./async.js"; +import { formatMessageResponse, normalizeDirectory } from "./helpers.js"; + +export type JobStatus = "accepted" | "running" | "input_required" | "completed" | "failed" | "cancelled" | "unknown"; +export interface PendingInput { + id: string; + kind: "question" | "permission"; + sessionID: string; + [key: string]: unknown; +} +export interface JobSnapshot { + jobId?: string; + sessionId?: string; + messageId?: string; + directory?: string; + status: JobStatus; + result?: unknown; + text?: string; + error?: unknown; + inputs?: PendingInput[]; + session?: unknown; + createdAt?: number; + updatedAt?: number; + expiresAt?: number; +} +export interface JobStartArgs { + prompt: string; + sessionId?: string; + title?: string; + directory?: string; + model?: { providerID: string; modelID: string }; + variant?: string; + agent?: string; + format?: unknown; +} +export interface JobInputResponse { + id: string; + kind: "question" | "permission"; + answers?: string[][]; + reply?: "once" | "always" | "reject"; + reject?: boolean; + message?: string; +} +export interface JobServiceOptions { + /** null creates a process-local store for isolated tests or ephemeral clients. */ + storeRoot?: string | null; + /** Opaque identity supplied by the caller, including auth identity if used. */ + scope?: string; + directory?: string; + ttlMs?: number; +} +interface StoredJob extends JobSnapshot { + jobId: string; + createdAt: number; + updatedAt: number; + expiresAt: number; + version: 1; + /** Persisted before sending each reply: an uncertain reply is never replayed. */ + responses: Record; + cancelRequested?: boolean; +} +const terminal = (status: JobStatus) => ["completed", "failed", "cancelled"].includes(status); +const hash = (value: string) => createHash("sha256").update(value).digest("hex"); +const segment = (value: string) => encodeURIComponent(value); + +function publicSnapshot(job: StoredJob): JobSnapshot { + const { version: _version, responses: _responses, cancelRequested: _cancelRequested, ...snapshot } = job; + return structuredClone(snapshot); +} +function requestError(error: unknown): { name: string; message: string; status?: number } { + // Persist operational diagnostics, never the submitted prompt or request body. + if (error instanceof OpenCodeSubmissionError) return { name: error.name, message: "Submission outcome unknown; check this job before retrying." }; + if (error instanceof OpenCodeError) return { name: error.name, message: "OpenCode rejected the request", status: error.status }; + return { name: error instanceof Error ? error.name : "Error", message: "The request could not be completed" }; +} + +/** Match upstream's ascending message ID layout (12 hex timestamp + 14 random characters). + * See OpenCode v1.18.31 packages/opencode/src/id/id.ts. The supplied prefix is msg_. */ +let lastTimestamp = 0; +let idCounter = 0; +export function createMessageId(): string { + const now = Date.now(); + idCounter = now === lastTimestamp ? idCounter + 1 : 1; + lastTimestamp = now; + const encoded = (BigInt(now) * 4096n + BigInt(idCounter)).toString(16).slice(-12).padStart(12, "0"); + return `msg_${encoded}${randomBytes(7).toString("hex")}`; +} + +async function pendingInputs(client: OpenCodeClient, sessionId: string, directory: string | undefined, options: RequestOptions): Promise { + const values = await Promise.all((["question", "permission"] as const).map(async (kind) => { + try { + const response = await client.get(`/${kind}`, undefined, directory, options); + if (!Array.isArray(response)) throw new Error(`Invalid ${kind} list response`); + return response.filter((input: any) => input?.sessionID === sessionId && typeof input.id === "string") + .map((input: any) => ({ ...input, kind } as PendingInput)); + } catch (error) { + if (error instanceof OpenCodeError && error.status === 404) return []; + throw error; + } + })); + return values.flat(); +} + +/** Observe a specific dispatched turn, or the latest user turn in a legacy session. */ +export async function observeSession( + client: OpenCodeClient, sessionId: string, directory?: string, messageId?: string, options: RequestOptions = {}, +): Promise { + if (!sessionId) throw new Error("sessionId is required"); + directory = normalizeDirectory(directory); + const context = createRequestContext(options); + const request = { signal: context.signal, deadline: context.deadline }; + let historyIncomplete = false; + const readMessages = async () => { + const path = `/session/${segment(sessionId)}/message`; + try { + return await client.get(path, { limit: "100" }, directory, request); + } catch (error) { + // OpenCode 1.18.31 cannot encode persisted OutputFormat class instances + // on user messages. A latest-assistant-only read avoids that broken row. + // Require a known parent ID; partial history cannot establish ownership. + if (!messageId || !isOutputFormatEncodingError(error)) throw error; + historyIncomplete = true; + try { + return await client.get(path, { limit: "1" }, directory, request); + } catch (latestError) { + // Before the assistant exists, even the latest row is the broken user. + if (!isOutputFormatEncodingError(latestError)) throw latestError; + return []; + } + } + }; + try { + const [statuses, session, response] = await Promise.all([ + client.get>("/session/status", undefined, directory, request), + client.get(`/session/${segment(sessionId)}`, undefined, directory, request), + readMessages(), + ]); + if (!Array.isArray(response)) throw new Error("Invalid session message response"); + const messages = [...response].sort((a: any, b: any) => + (a?.info?.time?.created ?? 0) - (b?.info?.time?.created ?? 0) || String(a?.info?.id ?? "").localeCompare(String(b?.info?.id ?? ""))); + const latestUser = messages.filter((message: any) => message?.info?.role === "user").at(-1); + const target = messageId ?? latestUser?.info?.id; + const assistant = messages.filter((message: any) => message?.info?.role === "assistant" && (!target || message.info.parentID === target)).at(-1); + const rawStatus = statuses?.[sessionId]; + const state = typeof rawStatus === "string" ? rawStatus : rawStatus?.type ?? rawStatus?.state ?? "idle"; + const snapshot: JobSnapshot = { sessionId, directory, messageId: target, status: "accepted", session }; + if (assistant) { + snapshot.result = assistant.info.structured ?? assistant; + snapshot.text = formatMessageResponse(assistant); + if (assistant.info.error) { + snapshot.error = assistant.info.error; + snapshot.status = assistant.info.error.name === "MessageAbortedError" ? "cancelled" : "failed"; + return snapshot; + } + // A tool-call step can complete while the turn continues. Require idle, + // a completed assistant and a final finish reason when supplied. + // Validated structured output itself ends the turn, even though OpenCode + // preserves the StructuredOutput step's "tool-calls" finish reason. + if ((state === "idle" || (target && latestUser && latestUser.info.id !== target)) && typeof assistant.info.time?.completed === "number" && + (assistant.info.structured !== undefined || !["tool-calls", "unknown"].includes(assistant.info.finish))) { + snapshot.status = "completed"; + return snapshot; + } + } + if (state === "error" || state === "failed") { + snapshot.status = "failed"; + snapshot.error = rawStatus?.error ?? { name: "SessionError", message: "Session reported an error" }; + return snapshot; + } + if (state === "busy" || state === "retry" || state === "running") snapshot.status = "running"; + if (historyIncomplete) { + snapshot.status = "unknown"; + snapshot.error = { name: "MessageHistoryUnavailable", message: "OpenCode could not serialize message history. No completed result for the requested turn is available yet; observe this job again without resubmitting." }; + snapshot.inputs = []; + return snapshot; + } + // A session can contain another active turn. Never offer its permissions + // as inputs for an older job, even if that job's result is unavailable. + if (target && latestUser && latestUser.info.id !== target) { + snapshot.status = "unknown"; + snapshot.inputs = []; + return snapshot; + } + snapshot.inputs = await pendingInputs(client, sessionId, directory, request); + if (snapshot.inputs.length) snapshot.status = "input_required"; + return snapshot; + } finally { + context.dispose(); + } +} + +function isOutputFormatEncodingError(error: unknown): boolean { + if (!(error instanceof OpenCodeError) || error.status !== 400 || error.method !== "GET") return false; + try { + const body = JSON.parse(error.body); + return body?.name === "BadRequest" && body.data?.kind === "Body" && + typeof body.data.message === "string" && + /^Expected OutputFormat(?:JsonSchema|Text), got /.test(body.data.message) && + body.data.message.includes('["info"]["format"]'); + } catch { + return false; + } +} + +/** Durable ownership and observation handles. Prompt execution remains owned by OpenCode. */ +export class JobService { + private readonly store: string | null; + private readonly ttl: number; + private readonly directory?: string; + private readonly memory = new Map(); + private readonly memoryLocks = new Set(); + + constructor(private readonly client: OpenCodeClient, options: JobServiceOptions = {}) { + this.ttl = options.ttlMs ?? 24 * 60 * 60 * 1000; + if (!Number.isFinite(this.ttl) || this.ttl <= 0) throw new Error("Job TTL must be positive"); + this.directory = normalizeDirectory(options.directory); + const scope = hash(JSON.stringify([client.getBaseUrl(), options.scope ?? "default"])); + this.store = options.storeRoot === null ? null : join(options.storeRoot ?? join(process.platform === "win32" ? (process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local")) : (process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state")), "opencode-mcp", "tasks"), scope); + } + + private checkId(jobId: string): void { + if (!/^job_[a-f0-9]{32}$/.test(jobId)) throw new Error("Invalid job ID"); + } + private async prepare(): Promise { + if (this.store) { + await mkdir(this.store, { recursive: true, mode: 0o700 }); + await chmod(this.store, 0o700); + } + } + private async read(jobId: string): Promise { + this.checkId(jobId); + let job: StoredJob; + try { + job = this.store ? JSON.parse(await readFile(join(this.store, `${jobId}.json`), "utf8")) : structuredClone(this.memory.get(jobId)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error("Job not found in this server and credential scope"); + throw error; + } + if (!job || job.version !== 1 || job.jobId !== jobId) throw new Error("Job not found or invalid job record"); + if (job.expiresAt <= Date.now()) { + if (this.store) await rm(join(this.store, `${jobId}.json`), { force: true }); + else this.memory.delete(jobId); + throw new Error("Job expired; observe its OpenCode session directly if still available"); + } + return job; + } + private async save(job: StoredJob): Promise { + job.updatedAt = Date.now(); + if (!this.store) { this.memory.set(job.jobId, structuredClone(job)); return; } + await this.prepare(); + const path = join(this.store, `${job.jobId}.json`); + const temporary = `${path}.${randomUUID()}.tmp`; + const handle = await open(temporary, "wx", 0o600); + try { + await handle.writeFile(JSON.stringify(job), "utf8"); + await handle.sync(); + await handle.close(); + await rename(temporary, path); + } finally { + await handle.close().catch(() => {}); + await rm(temporary, { force: true }); + } + } + + private async locked(jobId: string, options: RequestOptions, action: () => Promise): Promise { + this.checkId(jobId); + const operation = createRequestContext(options); + try { await this.prepare(); } catch (error) { operation.dispose(); throw error; } + const context = createRequestContext({ signal: operation.signal, deadline: operation.deadline, timeout: Math.min(options.timeout ?? 5000, 5000) }); + let release: (() => Promise) | undefined; + try { + while (!release) { + throwIfAborted(context.signal); + if (!this.store) { + if (!this.memoryLocks.has(jobId)) { + this.memoryLocks.add(jobId); + release = async () => { this.memoryLocks.delete(jobId); }; + } + } else { + const lock = join(this.store, `${jobId}.lock`); + try { + await mkdir(lock, { mode: 0o700 }); + await open(join(lock, "owner.json"), "wx", 0o600).then(async (file) => { + try { await file.writeFile(JSON.stringify({ pid: process.pid, host: hostname() })); } + finally { await file.close(); } + }); + release = async () => { + await rm(join(lock, "owner.json"), { force: true }); + // No recursive deletion: a competing recovery guard must never + // be removed by the previous owner. + await rmdir(lock); + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + await this.recoverDeadOwner(lock); + } + } + if (!release) await abortableSleep(25, context.signal); + } + } catch (error) { + operation.dispose(); + throw error; + } finally { context.dispose(); } + try { + throwIfAborted(operation.signal); + return await withRequestOptions({ signal: operation.signal, deadline: operation.deadline }, action); + } finally { + operation.dispose(); + await release?.(); + } + } + + private async recoverDeadOwner(lock: string): Promise { + let owner: { pid: number; host: string }; + try { owner = JSON.parse(await readFile(join(lock, "owner.json"), "utf8")); } + catch (error) { + // Missing/partial metadata may belong to a live process still acquiring + // the lock. Fail closed after the bounded lock wait, never steal it. + if (["ENOENT"].includes((error as NodeJS.ErrnoException).code ?? "") || error instanceof SyntaxError) return; + throw error; + } + if (owner.host !== hostname() || !Number.isInteger(owner.pid) || owner.pid <= 0) return; + try { process.kill(owner.pid, 0); return; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") return; } + // Only one process may reclaim this directory. Once the directory is + // removed no operation below touches that pathname again. + let guard; + try { guard = await open(join(lock, "recovery"), "wx", 0o600); } + catch (error) { + if (["EEXIST", "ENOENT"].includes((error as NodeJS.ErrnoException).code ?? "")) return; + throw error; + } + await guard.close(); + try { + const current = JSON.parse(await readFile(join(lock, "owner.json"), "utf8")); + if (current.pid !== owner.pid || current.host !== owner.host) return; + await rm(join(lock, "owner.json")); + } finally { await rm(join(lock, "recovery"), { force: true }); } + try { await rmdir(lock); } + catch (error) { if (!["ENOENT", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; } + } + + async start(args: JobStartArgs, options: RequestOptions = {}): Promise { + if (typeof args.prompt !== "string" || !args.prompt.trim()) throw new Error("prompt is required"); + const directory = normalizeDirectory(args.directory ?? this.directory); + const now = Date.now(); + const job: StoredJob = { + version: 1, jobId: `job_${randomUUID().replaceAll("-", "")}`, sessionId: args.sessionId, messageId: createMessageId(), + directory, status: "unknown", createdAt: now, updatedAt: now, expiresAt: now + this.ttl, responses: {}, + }; + return this.locked(job.jobId, options, async () => { + const context = createRequestContext(options); + const request = { signal: context.signal, deadline: context.deadline, directory }; + try { + throwIfAborted(context.signal); + await this.save(job); // Record ownership before the first remote mutation. + try { + if (!job.sessionId) { + const session = await this.client.post<{ id: string }>("/session", { title: args.title ?? "MCP delegated task" }, request); + if (typeof session?.id !== "string" || !session.id) throw new Error("OpenCode returned no session ID"); + job.sessionId = session.id; + await this.save(job); + } + await this.client.post(`/session/${segment(job.sessionId)}/prompt_async`, { + messageID: job.messageId, parts: [{ type: "text", text: args.prompt }], + ...(args.model ? { model: args.model } : {}), ...(args.variant ? { variant: args.variant } : {}), + ...(args.agent ? { agent: args.agent } : {}), ...(args.format ? { format: args.format } : {}), + }, request); + job.status = "accepted"; + } catch (error) { + job.status = error instanceof OpenCodeError ? "failed" : "unknown"; + job.error = requestError(error); + } + await this.save(job); + return publicSnapshot(job); + } finally { context.dispose(); } + }); + } + + private async refresh(job: StoredJob, options: RequestOptions): Promise { + if (terminal(job.status) || !job.sessionId) return job; + const observed = await observeSession(this.client, job.sessionId, job.directory, job.messageId, options); + // Absence of a turn cannot prove that an ambiguous submission failed. + if (job.status === "unknown" && (observed.status === "accepted" || (job.cancelRequested && !terminal(observed.status)))) observed.status = "unknown"; + const updated = { ...job, ...observed }; + if (observed.status !== "unknown") delete updated.error; + if (observed.error) updated.error = observed.error; + await this.save(updated); + return updated; + } + + async get(jobId: string, options: RequestOptions = {}): Promise { + return this.locked(jobId, options, async () => publicSnapshot(await this.refresh(await this.read(jobId), options))); + } + + async list(limit = 50): Promise { + if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw new Error("limit must be an integer between 1 and 1000"); + await this.prepare(); + const ids = this.store ? (await readdir(this.store)).filter((name) => /^job_[a-f0-9]{32}\.json$/.test(name)).map((name) => name.slice(0, -5)) : [...this.memory.keys()]; + const values: JobSnapshot[] = []; + for (const id of ids) { + try { values.push(publicSnapshot(await this.read(id))); } + catch (error) { if (!(error instanceof Error && /expired|not found/.test(error.message))) throw error; } + } + return values.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)).slice(0, limit); + } + + async cancel(jobId: string, options: RequestOptions = {}): Promise { + return this.locked(jobId, options, async () => { + let job = await this.read(jobId); + if (terminal(job.status)) return publicSnapshot(job); + if (!job.sessionId) throw new Error("Cannot abort a job whose session creation outcome is unknown"); + job = await this.refresh(job, options); + if (terminal(job.status)) return publicSnapshot(job); + if (!job.sessionId) throw new Error("No known session to cancel"); + // OpenCode's abort endpoint is session-wide: refuse to abort a newer turn. + const messages = await this.client.get(`/session/${segment(job.sessionId)}/message`, { limit: "100" }, job.directory, options); + const users = messages.filter((message) => message?.info?.role === "user"); + const newest = users.sort((a, b) => (a.info.time?.created ?? 0) - (b.info.time?.created ?? 0) || String(a.info.id).localeCompare(String(b.info.id))).at(-1); + if (newest && newest.info.id !== job.messageId) throw new Error("A newer turn owns this session; refusing to abort unrelated work"); + if (job.cancelRequested) return publicSnapshot(job); + job.cancelRequested = true; + await this.save(job); + try { + await this.client.post(`/session/${segment(job.sessionId)}/abort`, {}, { ...options, directory: job.directory }); + job.status = "cancelled"; + delete job.error; + } catch (error) { + job.status = "unknown"; + job.error = requestError(error); + if (error instanceof OpenCodeError) job.cancelRequested = false; + } + await this.save(job); + return publicSnapshot(job); + }); + } + + async update(jobId: string, responses: JobInputResponse[], options: RequestOptions = {}): Promise { + if (!Array.isArray(responses) || responses.length < 1 || responses.length > 100) throw new Error("Provide between 1 and 100 input responses"); + return this.locked(jobId, options, async () => { + let job = await this.read(jobId); + if (terminal(job.status)) return publicSnapshot(job); + job = await this.refresh(job, options); + for (const response of responses) { + if (!response || typeof response.id !== "string" || !["question", "permission"].includes(response.kind)) throw new Error("Invalid input response"); + const key = `${response.kind}:${response.id}`; + const digest = hash(JSON.stringify({ id: response.id, kind: response.kind, answers: response.answers, reply: response.reply, reject: response.reject, message: response.message })); + const previous = job.responses[key]; + if (previous) { + if (previous.digest !== digest) throw new Error("This input already has a different submitted response"); + if (previous.state !== "sent") throw new Error("Previous reply outcome is unknown; it cannot be safely resent"); + continue; + } + if (!job.inputs?.some((input) => input.id === response.id && input.kind === response.kind)) throw new Error("Input does not belong to this job's pending requests"); + if (response.kind === "permission" && (!["once", "always", "reject"].includes(response.reply ?? "") || response.answers !== undefined || response.reject !== undefined)) throw new Error("Permission response requires once, always, or reject without question answers"); + if (response.kind === "question" && (response.reply !== undefined || (response.reject === true && response.answers !== undefined))) throw new Error("Question response requires answers or reject=true, exclusively"); + if (response.kind === "question" && !response.reject && (!Array.isArray(response.answers) || !response.answers.every((answers) => Array.isArray(answers) && answers.every((answer) => typeof answer === "string")))) throw new Error("Question response requires an array of answer arrays"); + job.responses[key] = { digest, state: "sending" }; + await this.save(job); + try { + const path = `/${response.kind}/${segment(response.id)}/${response.kind === "question" && response.reject ? "reject" : "reply"}`; + const body = response.kind === "permission" ? { reply: response.reply, ...(response.message ? { message: response.message } : {}) } : response.reject ? undefined : { answers: response.answers }; + await this.client.post(path, body, { ...options, directory: job.directory }); + job.responses[key].state = "sent"; + } catch (error) { + if (error instanceof OpenCodeError) delete job.responses[key]; + else job.responses[key].state = "unknown"; + job.error = requestError(error); + await this.save(job); + throw error; + } + await this.save(job); + } + return publicSnapshot(await this.refresh(job, options)); + }); + } +} diff --git a/src/mcp-server.ts b/src/mcp-server.ts new file mode 100644 index 0000000..1ef8c08 --- /dev/null +++ b/src/mcp-server.ts @@ -0,0 +1,84 @@ +/** Shared registration contract. SDK v2 owns protocol validation and transport serving. */ +import { McpServer as SdkServer, ResourceTemplate, type ServerContext, type ToolAnnotations, + type ResourceMetadata, type ReadResourceCallback, type ReadResourceTemplateCallback, + type InputRequiredResult, type GetPromptResult, type CallToolResult } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { withRequestOptions } from "./async.js"; + +export { ResourceTemplate }; +export type ToolContext = { signal?: AbortSignal; _meta?: Record; mcpReq?: ServerContext["mcpReq"] }; +export type ToolProfile = "full" | "essential"; + +const readers = new Set(`health setup status context conversation sessions_overview check wait review_changes + config_get config_providers provider_list provider_models provider_auth_methods + session_list session_get session_children session_status session_todo session_diff session_search permission_list + message_list message_get find_text find_file find_symbol file_list file_read file_status + project_list project_current path_get vcs_info agent_list command_list lsp_status formatter_status + mcp_status tool_ids tool_list events_poll question_list job_get job_list`.split(/\s+/).map(n => `opencode_${n}`)); +const essential = new Set(`setup provider_list provider_models ask reply run fire check wait conversation review_changes context status + session_abort session_create session_list permission_list session_permission question_list question_reply question_reject + job_get job_list job_cancel job_input`.split(/\s+/).map(n => `opencode_${n}`)); + +export function annotationsFor(name: string): ToolAnnotations { + const readOnly = readers.has(name); + return { readOnlyHint: readOnly, destructiveHint: !readOnly, + idempotentHint: readOnly, openWorldHint: true }; +} + +export const jobStatusSchema = z.enum(["accepted", "running", "input_required", "completed", "failed", "cancelled", "unknown"]); +function outputSchema(name: string) { + const common = { text: z.string(), isError: z.boolean() }; + if (["opencode_run", "opencode_fire", "opencode_wait", "opencode_check", "opencode_job_get", "opencode_job_cancel"].includes(name)) { + return z.object({ ...common, sessionId: z.string().optional(), jobId: z.string().optional(), + messageId: z.string().optional(), directory: z.string().optional(), status: jobStatusSchema, + result: z.unknown().optional(), error: z.unknown().optional() }).loose(); + } + if (name === "opencode_session_create") return z.object({ ...common, sessionId: z.string(), session: z.unknown() }).loose(); + return z.object({ ...common, data: z.unknown().optional() }).loose(); +} + +export function withStructuredText(result: CallToolResult): CallToolResult { + return { ...result, structuredContent: { + ...(result.structuredContent && typeof result.structuredContent === "object" ? result.structuredContent : {}), + text: result.content.filter(c => c.type === "text").map(c => c.text).join("\n"), + isError: result.isError === true, + } }; +} + +export class McpServer extends SdkServer { + profile: ToolProfile = "full"; + readonly catalog: Array<{ name: string; description: string; inputSchema: Record; + outputSchema: Record; annotations: ToolAnnotations }> = []; + + tool(name: string, description: string, shape: S, + ...rest: [handler: (args: z.infer>, extra?: ToolContext) => Promise] + | [annotations: ToolAnnotations, handler: (args: z.infer>, extra?: ToolContext) => Promise]) { + if (this.profile === "essential" && !essential.has(name)) return; + const handler = rest[rest.length - 1] as (args: z.infer>, extra?: ToolContext) => Promise; + // Central explicit inventory is authoritative; scattered legacy hints may be incomplete. + const annotations = annotationsFor(name); + const input = z.object(shape); + const output = outputSchema(name); + this.catalog.push({ name, description, inputSchema: z.toJSONSchema(input), outputSchema: z.toJSONSchema(output), annotations }); + return this.registerTool(name, { description, inputSchema: input, outputSchema: output, annotations }, + async (args, ctx) => withRequestOptions({ signal: ctx.mcpReq.signal }, async () => { + const result = await handler(args, { signal: ctx.mcpReq.signal, _meta: ctx.mcpReq._meta, mcpReq: ctx.mcpReq }); + return result.resultType === "input_required" ? result as InputRequiredResult : withStructuredText(result as CallToolResult); + })); + } + + resource(name: string, uri: string, metadata: ResourceMetadata, handler: ReadResourceCallback): void; + resource(name: string, uri: ResourceTemplate, metadata: ResourceMetadata, handler: ReadResourceTemplateCallback): void; + resource(name: string, uri: string | ResourceTemplate, metadata: ResourceMetadata, + handler: ReadResourceCallback | ReadResourceTemplateCallback): void { + if (typeof uri === "string") this.registerResource(name, uri, metadata, + (url, ctx) => withRequestOptions({ signal: ctx.mcpReq.signal }, () => (handler as ReadResourceCallback)(url, ctx))); + else this.registerResource(name, uri, metadata, + (url, variables, ctx) => withRequestOptions({ signal: ctx.mcpReq.signal }, () => (handler as ReadResourceTemplateCallback)(url, variables, ctx))); + } + + prompt(name: string, description: string, shape: S, + handler: (args: z.infer>) => Promise) { + return this.registerPrompt(name, { description, argsSchema: z.object(shape) }, handler); + } +} diff --git a/src/prompts.ts b/src/prompts.ts index 136a550..d48d48d 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -7,7 +7,7 @@ */ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "./mcp-server.js"; export function registerPrompts(server: McpServer) { // ─── Code Review ────────────────────────────────────────────────── @@ -28,7 +28,7 @@ export function registerPrompts(server: McpServer) { text: `Please review the code changes in OpenCode session "${sessionId}". Steps: -1. Use opencode_review_changes with sessionId "${sessionId}" to get the diff +1. Confirm the session's project directory and use opencode_review_changes with sessionId "${sessionId}" and that directory to get the diff 2. Analyze the changes for: - Correctness and potential bugs - Code style and best practices @@ -65,12 +65,12 @@ Issue: ${issue} ${context ? `\nContext: ${context}` : ""} Steps: -1. Use opencode_context to understand the project setup -2. Use opencode_ask with the agent "build" to investigate the issue: - - Search for relevant files with opencode_find_text and opencode_find_file - - Read the relevant source code with opencode_file_read - - Analyze the code and identify the root cause -3. Suggest a fix and optionally have OpenCode implement it`, +1. Use opencode_setup and opencode_context with the intended absolute project directory +2. Discover a configured provider/model, or use both configured defaults +3. Use opencode_ask for a short investigation or opencode_fire for longer work. Ask OpenCode to identify the root cause and relevant evidence before changing files +4. Keep the returned jobId, sessionId, and messageId. For background work, observe with opencode_check or opencode_job_get +5. If input_required, present the pending question or permission and forward the user's explicit response with opencode_job_input +6. Suggest a fix and implement it within the user's authorized scope. Report evidence and validation`, }, }, ], @@ -91,11 +91,10 @@ Steps: text: `Help me understand this project. Steps: -1. Use opencode_context to get project info, VCS status, and available agents -2. Use opencode_file_list to see the project structure -3. Look for key files: README, package.json, config files, entry points -4. Use opencode_file_read on the most important files -5. Provide a summary of: +1. Use opencode_setup and opencode_context with the intended absolute project directory +2. If file tools are advertised, use opencode_file_list and opencode_file_read to inspect README, package metadata, configuration, and entry points +3. Otherwise ask OpenCode to inspect those files without changing them, using a configured provider/model through opencode_ask +4. Provide a summary of: - What the project does - Tech stack and dependencies - Project structure @@ -132,11 +131,13 @@ ${description} ${requirements ? `\nRequirements: ${requirements}` : ""} Steps: -1. Use opencode_context to understand the project -2. Use opencode_ask with the "build" agent to implement the feature: - "Please implement: ${description}${requirements ? `. Requirements: ${requirements}` : ""}" -3. Use opencode_review_changes to see what was changed -4. Report back what was implemented and any follow-up items`, +1. Use opencode_setup and opencode_context with the intended absolute project directory; discover a configured provider/model or use both configured defaults +2. Use opencode_run or opencode_fire with the "build" agent and an explicit project directory to implement the feature: + "Please implement: ${description}${requirements ? `. Requirements: ${requirements}` : ""}. Run the relevant tests and report their results." +3. Save jobId, sessionId, and messageId. Observe existing work with opencode_job_get, opencode_check, or opencode_wait; a timeout is not a reason to dispatch the same task again +4. If input_required, present the pending question or permission and use opencode_job_input only with the user's explicit answer +5. After completion, use opencode_review_changes for the same session and directory +6. Report what was implemented, the test results, and any remaining work`, }, }, ], @@ -156,59 +157,45 @@ Steps: type: "text" as const, text: `# OpenCode MCP Best Practices -## 1. First-Time Setup -- Always start with \`opencode_setup\` to check server health and see available providers. -- Pick a provider from the **Ready to use** list, then call \`opencode_provider_models\` to see its models. -- Test a provider with \`opencode_provider_test\` if you're unsure it's working. - -## 2. Always Specify Provider and Model -CRITICAL: When calling \`opencode_ask\`, \`opencode_reply\`, \`opencode_message_send\`, or \`opencode_message_send_async\`, ALWAYS pass \`providerID\` and \`modelID\`. Without these, the agent may select a default model that returns empty responses. Use providers discovered via \`opencode_setup\` — do NOT hardcode any specific provider. - -Good: \`opencode_ask({prompt: "...", providerID: "", modelID: ""})\` -Bad: \`opencode_ask({prompt: "..."})\` - -## 3. Choosing the Right Tool - -| Task | Tool | Why | -|------|------|-----| -| Quick question | \`opencode_ask\` | One call, creates session + gets response | -| Multi-turn conversation | \`opencode_ask\` then \`opencode_reply\` | Builds on existing session | -| Complex build task (< 10 min) | \`opencode_run\` | One call, creates session + polls until done | -| Very long task (10+ min) | \`opencode_fire\` + \`opencode_check\` | Fire-and-forget with cheap progress checks | -| Monitor a running session | \`opencode_check\` | Status, todos, file counts in one call | - -## 4. Writing Good Prompts for OpenCode -The agent works best with structured, specific prompts: -- Specify the tech stack explicitly -- List all features/requirements as bullet points -- Define the project structure you want -- State what tests you expect -- Say "Run npm run build and fix any errors" at the end - -## 5. Monitoring Long-Running Tasks -- \`opencode_check\` — quick progress report: status, todos, file counts (cheapest) -- \`opencode_session_todo\` — see the agent's internal checklist -- \`opencode_wait\` — block until done, but has a timeout -- \`opencode_conversation\` — see full message history (expensive, lots of tokens) -- \`opencode_review_changes\` — see all file diffs (use after task completes) - -## 6. Error Recovery -- If a session fails, use \`opencode_reply\` to give the agent the error and ask it to fix -- If the server is unreachable, call \`opencode_setup\` to diagnose -- If auth fails, use \`opencode_auth_set\` to update API keys -- If a session is stuck, use \`opencode_session_abort\` then retry - -## 7. Tool Annotations -Tools are annotated with behavior hints: -- \`readOnlyHint: true\` — safe, doesn't change anything (setup, status, find, review) -- \`destructiveHint: true\` — permanently deletes data (session_delete, instance_dispose) -- No annotation — has side effects but is not destructive (ask, reply, send messages) - -## 8. Common Pitfalls -- Don't call \`opencode_conversation\` on active sessions — it's expensive and the response is still being generated -- Don't create new sessions for each message — use \`opencode_reply\` to continue existing ones -- Don't forget the \`directory\` parameter when working with multiple projects -- Don't call \`opencode_instance_dispose\` unless you really want to shut down the server`, +## Setup and Model Selection +- Start with opencode_setup and opencode_context for the intended absolute project directory. +- Discover provider/model IDs with opencode_provider_list and opencode_provider_models. +- Pass providerID and modelID together, or configure both OPENCODE_DEFAULT_PROVIDER and OPENCODE_DEFAULT_MODEL. +- A provider test invokes a model and can incur charges; use it when model verification is needed. + +## Choosing Tools +- opencode_ask and opencode_reply: short questions and follow-up conversations. +- opencode_run: dispatch work and observe it for a bounded period; capable clients can use the native MCP Tasks extension. +- opencode_fire: dispatch and return immediately. Save jobId, sessionId, and messageId. +- opencode_check or opencode_job_get: inspect existing work. opencode_wait observes until completion, required input, or its deadline. +- opencode_job_list: rediscover retained jobs after reconnecting. +- opencode_review_changes: inspect changes after completion; use bounded opencode_conversation reads for details. + +## State and Required Input +- Read structuredContent.status: accepted, running, input_required, completed, failed, cancelled, or unknown. +- A timedOut response ends observation; the OpenCode task may still be running. +- input_required means a pending permission or question needs a response. Use opencode_job_input for an interactive form when supported, or submit explicit responses. +- Only approve permissions that the user has authorized. Do not change the project to blanket permission allow to bypass a pending request. +- Use question_list/question_reply/question_reject and permission_list/session_permission for low-level control when needed. + +## Recovery and Cancellation +- Check existing work before retrying a failed or ambiguous submission; do not automatically submit the prompt twice. +- Cancelling an observation or disconnecting MCP does not itself request a remote abort. Use opencode_job_cancel or opencode_session_abort to stop work explicitly. +- Persisted handles/results expire after 24 hours; expiry does not abort the OpenCode session. +- OpenCode must remain running for background execution. An auto-started child closes with MCP, while an externally managed server can keep working. +- Poll for status; this server does not promise push notifications or wake an idle assistant. + +## Writing Useful Requests +- State the expected behavior, constraints, and relevant tests. +- Use optional variant values supported by the selected model. +- Use format: {type: "json_schema", schema: {...}} when a machine-readable model answer is needed. + +## Common Pitfalls +- Use an absolute directory on the OpenCode host, including for follow-ups. A session alone is not filesystem isolation; parallel edits should use separate projects or worktrees. +- Static resources use the default project. Use encoded project/session resource templates for another directory; resource subscriptions are not implemented. +- The essential profile advertises fewer tools. Use only tools actually listed by the client; select full for specialist APIs. +- Every tool has behavior annotations, but hints do not grant authorization. Coding workflows remain capable of changing files according to OpenCode permissions. +- Large JSON responses use a truncation envelope; its preview is a text prefix, not a complete JSON document.`, }, }, ], @@ -231,9 +218,9 @@ Tools are annotated with behavior hints: text: `Please summarize OpenCode session "${sessionId}". Steps: -1. Use opencode_session_get to get session metadata -2. Use opencode_conversation with sessionId "${sessionId}" to read the full history -3. Use opencode_review_changes with sessionId "${sessionId}" to see file changes +1. Confirm the session's project directory and use opencode_check with sessionId "${sessionId}" to inspect its current state +2. Use bounded opencode_conversation reads with the same sessionId and directory to inspect relevant history +3. Use opencode_review_changes with sessionId "${sessionId}" and that directory to see file changes 4. Provide a summary including: - What was discussed/requested - What actions were taken diff --git a/src/resources.ts b/src/resources.ts index 463968a..7f596e6 100644 --- a/src/resources.ts +++ b/src/resources.ts @@ -6,9 +6,9 @@ * things like "show me the current project" or "list available sessions". */ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, ResourceTemplate } from "./mcp-server.js"; import { OpenCodeClient } from "./client.js"; -import { safeStringify } from "./helpers.js"; +import { normalizeDirectory, redactSecrets, safeStringify } from "./helpers.js"; export function registerResources(server: McpServer, client: OpenCodeClient) { // ─── Current Project ────────────────────────────────────────────── @@ -60,7 +60,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { { uri: "opencode://config", mimeType: "application/json", - text: safeStringify(config), + text: safeStringify(redactSecrets(config)), }, ], }; @@ -83,7 +83,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { { uri: "opencode://providers", mimeType: "application/json", - text: safeStringify(providers), + text: safeStringify(redactSecrets(providers)), }, ], }; @@ -190,7 +190,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { }, ); - // ─── Session list (resource template) ───────────────────────────── + // ─── Default-project session list ──────────────────────────────── server.resource( "sessions", "opencode://sessions", @@ -255,4 +255,40 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { }; }, ); + + // URI path variables are percent-encoded components, including the complete + // server-side directory. Never resolve these paths against the MCP host. + const scalar = (value: string | string[] | undefined, name: string): string => { + if (typeof value !== "string" || !value) throw new Error(`Missing resource ${name}`); + return decodeURIComponent(value); + }; + for (const [name, suffix, endpoint] of [ + ["project-scoped", "current", "/project/current"], + ["sessions-scoped", "sessions", "/session"], + ] as const) { + server.resource(name, + new ResourceTemplate(`opencode://projects/{directory}/${suffix}`, { list: undefined }), + { description: `Project-scoped ${suffix}; percent-encode the absolute server directory`, mimeType: "application/json" }, + async (uri, variables) => { + const directory = normalizeDirectory(scalar(variables.directory, "directory")); + const data = await client.get(endpoint, undefined, directory); + return { contents: [{ uri: uri.href, mimeType: "application/json", text: safeStringify(data) }] }; + }, + ); + } + for (const [name, suffix, endpointSuffix] of [ + ["session-scoped", "", ""], + ["session-messages-scoped", "/messages", "/message"], + ] as const) { + server.resource(name, + new ResourceTemplate(`opencode://projects/{directory}/sessions/{sessionId}${suffix}`, { list: undefined }), + { description: `Session ${suffix ? "messages" : "details"} in an explicitly scoped project`, mimeType: "application/json" }, + async (uri, variables) => { + const directory = normalizeDirectory(scalar(variables.directory, "directory")); + const sessionId = scalar(variables.sessionId, "sessionId"); + const data = await client.get(`/session/${encodeURIComponent(sessionId)}${endpointSuffix}`, undefined, directory); + return { contents: [{ uri: uri.href, mimeType: "application/json", text: safeStringify(data) }] }; + }, + ); + } } diff --git a/src/task-input.ts b/src/task-input.ts new file mode 100644 index 0000000..85158ee --- /dev/null +++ b/src/task-input.ts @@ -0,0 +1,70 @@ +/** Translate OpenCode requests into MCP form requests; every response is validated again. */ +import { inputRequired, type InputRequests } from "@modelcontextprotocol/server"; +import { z } from "zod"; + +export interface PendingRequest { + id: string; + kind: "question" | "permission"; + [key: string]: unknown; +} +export interface InputAnswer { + id: string; + kind: "question" | "permission"; + answers?: string[][]; + reply?: "once" | "always" | "reject"; + reject?: boolean; +} +export const inputAnswerSchema = z.object({ + id: z.string().min(1), kind: z.enum(["question", "permission"]), + answers: z.array(z.array(z.string())).optional(), + reply: z.enum(["once", "always", "reject"]).optional(), reject: z.boolean().optional(), +}).superRefine((a, ctx) => { + if (a.kind === "permission" && (!a.reply || a.answers || a.reject !== undefined)) + ctx.addIssue({ code: "custom", message: "Permission input requires reply only" }); + if (a.kind === "question" && ((a.answers === undefined) === (a.reject !== true) || a.reply)) + ctx.addIssue({ code: "custom", message: "Question input requires answers or reject=true, exclusively" }); +}); + +function questions(item: PendingRequest): Array<{ question?: string; options?: Array<{ label: string }>; multiple?: boolean }> { + return Array.isArray(item.questions) ? item.questions : []; +} +export function inputRequests(items: PendingRequest[]): InputRequests { + return Object.fromEntries(items.map(item => { + const key = `${item.kind}:${item.id}`; + if (item.kind === "permission") { + return [key, inputRequired.elicit({ message: `OpenCode requests ${String(item.permission ?? "permission")} for ${JSON.stringify(item.patterns ?? [])}`, + requestedSchema: { type: "object", properties: { decision: { type: "string", enum: ["once", "always", "reject"], description: "Choose explicitly; no approval is selected automatically" } }, required: ["decision"] } })]; + } + const qs = questions(item); + const properties = Object.fromEntries(qs.map((q, index) => [`answer_${index}`, { + type: "string" as const, + description: `${q.question ?? "Answer"}${q.options?.length ? ` Options: ${q.options.map(o => o.label).join(", ")}.` : ""}${q.multiple ? " For multiple choices, enter a JSON array of labels." : ""}`, + }])); + return [key, inputRequired.elicit({ message: qs.map(q => q.question).join("\n") || "OpenCode needs your input", + requestedSchema: { type: "object", properties, required: Object.keys(properties) } })]; + })); +} + +/** Unknown/already-consumed keys are ignored by the Tasks extension contract. */ +export function decodeInputResponses(items: PendingRequest[], responses: Record): InputAnswer[] { + const answer = z.object({ action: z.enum(["accept", "decline", "cancel"]), content: z.record(z.string(), z.unknown()).optional() }); + return items.flatMap(item => { + const raw = responses[`${item.kind}:${item.id}`]; + if (raw === undefined) return []; + const response = answer.parse(raw); + if (response.action !== "accept") return [{ id: item.id, kind: item.kind, + ...(item.kind === "permission" ? { reply: "reject" as const } : { reject: true }) }]; + if (item.kind === "permission") return [{ id: item.id, kind: item.kind, + reply: z.enum(["once", "always", "reject"]).parse(response.content?.decision) }]; + const answers = questions(item).map((q, index) => { + const value = z.string().parse(response.content?.[`answer_${index}`]); + return q.multiple ? z.array(z.string()).parse(JSON.parse(value)) : [value]; + }); + return [{ id: item.id, kind: item.kind, answers }]; + }); +} + +export function supportsForm(capabilities: unknown): boolean { + const caps = capabilities as { elicitation?: { form?: unknown } } | undefined; + return !!caps?.elicitation && typeof caps.elicitation.form === "object" && caps.elicitation.form !== null; +} diff --git a/src/task-transport.ts b/src/task-transport.ts new file mode 100644 index 0000000..0d65caa --- /dev/null +++ b/src/task-transport.ts @@ -0,0 +1,137 @@ +/** July 2026 Tasks extension bridge. SDK v2 currently handles core MCP only. + * Keep extension dispatch at the stdio boundary; all core traffic uses serveStdio. + */ +import type { Transport, JSONRPCMessage } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { JobService, type JobSnapshot } from "./jobs.js"; +import { runInputSchema } from "./job-contract.js"; +import { applyModelDefaults, toolResult } from "./helpers.js"; +import { withStructuredText } from "./mcp-server.js"; +import { decodeInputResponses, inputRequests, supportsForm } from "./task-input.js"; + +export const TASKS_EXTENSION = "io.modelcontextprotocol/tasks"; +const VERSION = "2026-07-28"; +const versionKey = "io.modelcontextprotocol/protocolVersion"; +const capsKey = "io.modelcontextprotocol/clientCapabilities"; +const envelopeSchema = z.looseObject({ + [versionKey]: z.literal(VERSION), + [capsKey]: z.looseObject({ + extensions: z.record(z.string(), z.record(z.string(), z.unknown())).optional(), + elicitation: z.looseObject({ form: z.record(z.string(), z.unknown()).optional(), url: z.record(z.string(), z.unknown()).optional() }).optional(), + }), + "io.modelcontextprotocol/clientInfo": z.looseObject({ name: z.string(), version: z.string() }).optional(), + "io.modelcontextprotocol/logLevel": z.enum(["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]).optional(), + progressToken: z.union([z.string(), z.number()]).optional(), +}); +const object = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); + +export function taskFromJob(job: JobSnapshot, detailed = false): Record { + const status = job.status === "cancelled" ? "cancelled" : job.status === "input_required" ? "input_required" + : ["completed", "failed"].includes(job.status) ? "completed" : "working"; + const task: Record = { + taskId: job.jobId, status, createdAt: new Date(job.createdAt!).toISOString(), + lastUpdatedAt: new Date(job.updatedAt!).toISOString(), ttlMs: job.expiresAt! - job.createdAt!, pollIntervalMs: 2000, + statusMessage: job.status === "unknown" ? "Submission outcome unknown; observation will not resend it." : job.status, + }; + if (detailed && status === "completed") task.result = { resultType: "complete", ...withStructuredText( + toolResult(job.text ?? (job.status === "failed" ? "OpenCode task failed" : "Completed"), job.status === "failed", { ...job })) }; + if (detailed && status === "input_required") task.inputRequests = inputRequests(job.inputs ?? []); + return { resultType: detailed ? "complete" : "task", ...task }; +} + +export class TaskTransport implements Transport { + onmessage?: Transport["onmessage"]; + onclose?: Transport["onclose"]; + onerror?: Transport["onerror"]; + private era?: "legacy" | "modern"; + private probing = false; + private readonly active = new Map(); + constructor(private readonly inner: Transport, private readonly jobs: JobService) {} + async start() { + this.inner.onclose = () => { this.abort(); this.onclose?.(); }; + this.inner.onerror = error => this.onerror?.(error); + this.inner.onmessage = (message, extra) => { + void this.receive(message, () => this.onmessage?.(message, extra)).catch(error => this.onerror?.(error)); + }; + await this.inner.start(); + } + send: Transport["send"] = (message, options) => this.inner.send(message, options); + setProtocolVersion = (version: string) => this.inner.setProtocolVersion?.(version); + setSupportedProtocolVersions = (versions: string[]) => this.inner.setSupportedProtocolVersions?.(versions); + private abort() { for (const controller of this.active.values()) controller.abort(); this.active.clear(); } + async close() { this.abort(); await this.inner.close(); } + private async receive(message: JSONRPCMessage, forward: () => void) { + if (!("method" in message)) { forward(); return; } + const request = message as { id?: string | number; method: string; params?: Record }; + if (request.method === "notifications/cancelled") { + this.active.get(request.params?.requestId)?.abort(); forward(); return; + } + const meta = request.params?._meta; + const modern = object(meta) && meta[versionKey] === VERSION; + // Do not pin a malformed opening; the SDK must still validate core metadata. + const validEnvelope = envelopeSchema.safeParse(meta).success; + const hasEnvelope = object(meta) && Object.keys(meta).some(key => [versionKey, capsKey, "io.modelcontextprotocol/clientInfo", "io.modelcontextprotocol/logLevel"].includes(key)); + const claimedVersion = object(meta) ? meta[versionKey] : undefined; + const requestError = async (code: number, message: string, data?: unknown) => { + if (request.id !== undefined) await this.send({ jsonrpc: "2.0", id: request.id, error: { code, message, ...(data ? { data } : {}) } }); + }; + if ((this.era === "modern" && !modern) || (this.era === "legacy" && modern)) { + await requestError(-32022, "Cannot change protocol era on an open connection", { supported: this.era === "modern" ? [VERSION] : ["2025-11-25"], requested: claimedVersion ?? request.params?.protocolVersion ?? "2025-11-25" }); + return; + } + if (!this.era) { + if (modern && validEnvelope) { + if (request.method === "server/discover") this.probing = true; + else if (!this.probing || request.id !== undefined) this.era = "modern"; + } else if (!hasEnvelope) this.era = "legacy"; + } + const caps = object(meta) ? meta[capsKey] : undefined; + const taskCapable = object(caps) && object(caps.extensions) && object(caps.extensions[TASKS_EXTENSION]); + const extensionMethod = request.method.startsWith("tasks/"); + const run = request.method === "tools/call" && request.params?.name === "opencode_run" && modern && taskCapable; + if (!extensionMethod && !run) { forward(); return; } + if (request.id === undefined) return; // Extension methods are requests, never notifications. + const id = request.id; + const fail = (code: number, message: string, data?: unknown) => this.send({ jsonrpc: "2.0", id, error: { code, message, ...(data ? { data } : {}) } }); + if (modern && !validEnvelope) { await fail(-32602, "Invalid MCP request metadata"); return; } + if (!modern || this.era !== "modern") { await fail(-32022, "Tasks require a 2026-07-28 connection", { supported: [VERSION], requested: claimedVersion ?? "2025-11-25" }); return; } + if (!taskCapable) { await fail(-32021, "Client must advertise the Tasks extension", { requiredCapabilities: { extensions: { [TASKS_EXTENSION]: {} } } }); return; } + if (!run && !["tasks/get", "tasks/update", "tasks/cancel"].includes(request.method)) { await fail(-32601, "Method not found"); return; } + if (this.active.has(id)) { await fail(-32600, "Duplicate active request ID"); return; } + const controller = new AbortController(); + this.active.set(id, controller); + try { + let result: Record; + if (run) { + const args = runInputSchema.parse(request.params?.arguments ?? {}); + const job = await this.jobs.start({ ...args, model: applyModelDefaults(args.providerID, args.modelID) }, { signal: controller.signal }); + result = taskFromJob(job); + } else { + const { taskId } = z.object({ taskId: z.string().min(1) }).parse(request.params); + if (request.method === "tasks/get") { + const job = await this.jobs.get(taskId, { signal: controller.signal }); + if (job.status === "input_required" && !supportsForm(caps)) { + await fail(-32021, "Pending input requires form elicitation", { requiredCapabilities: { elicitation: { form: {} } } }); + return; + } + result = taskFromJob(job, true); + } + else if (request.method === "tasks/cancel") { + // The extension acknowledges cancellation intent; get reports the eventual state. + await this.jobs.cancel(taskId, { signal: controller.signal }); + result = { resultType: "complete" }; + } else { + const responses = z.record(z.string(), z.unknown()).parse(request.params?.inputResponses); + const job = await this.jobs.get(taskId, { signal: controller.signal }); + const answers = decodeInputResponses(job.inputs ?? [], responses); + if (answers.length) await this.jobs.update(taskId, answers, { signal: controller.signal }); + result = { resultType: "complete" }; + } + } + await this.send({ jsonrpc: "2.0", id, result }); + } catch (error) { + await fail(error instanceof z.ZodError ? -32602 : -32603, + error instanceof z.ZodError ? "Invalid task parameters" : error instanceof Error ? error.message : "Task operation failed"); + } finally { this.active.delete(id); } + } +} diff --git a/src/tools/config.ts b/src/tools/config.ts index fc55864..16c9651 100644 --- a/src/tools/config.ts +++ b/src/tools/config.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; import { toolJson, toolError, toolResult, redactSecrets, safeStringify, directoryParam } from "../helpers.js"; @@ -63,7 +63,7 @@ export function registerConfigTools(server: McpServer, client: OpenCodeClient) { }, async ({ config, directory }) => { try { - return toolJson(await client.patch("/config", config, directory)); + return toolJson(redactSecrets(await client.patch("/config", config, directory))); } catch (e) { return toolError(e); } diff --git a/src/tools/events.ts b/src/tools/events.ts index fdcba05..1250df5 100644 --- a/src/tools/events.ts +++ b/src/tools/events.ts @@ -3,9 +3,10 @@ */ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; -import { toolResult, toolError, directoryParam } from "../helpers.js"; +import { createRequestContext } from "../async.js"; +import { toolResult, toolError, directoryParam, readOnly } from "../helpers.js"; export function registerEventTools( server: McpServer, @@ -13,65 +14,62 @@ export function registerEventTools( ) { server.tool( "opencode_events_poll", - "Poll for recent events from the OpenCode server. Collects events for the specified duration and returns them. Useful for monitoring session activity, deployments, and system changes.", + "Poll project events from OpenCode, or explicitly select global scope. Collects up to maxEvents within the duration. Connection failures are reported with any partial events; stopping observation does not abort remote work.", { durationMs: z - .number() + .number().int().min(1).max(30000) .optional() .describe( "How long to collect events in milliseconds (default: 3000, max: 30000)", ), maxEvents: z - .number() + .number().int().min(1).max(1000) .optional() - .describe("Maximum number of events to collect (default: 50)"), + .describe("Maximum number of events to collect (default: 50, max: 1000)"), directory: directoryParam, + scope: z.enum(["project", "global"]).optional().describe("Event scope (default: project). Global events include all projects and cannot be combined with directory."), }, - async ({ durationMs, maxEvents, directory: _directory }) => { + readOnly, + async ({ durationMs, maxEvents, directory, scope }, extra) => { + const events: Array<{ event: string; data: string }> = []; try { - const duration = Math.min(durationMs ?? 3000, 30000); - const max = maxEvents ?? 50; - const events: Array<{ event: string; data: string }> = []; - - // Note: SSE subscribeSSE does not currently support the directory - // header. The event stream is server-wide. The directory param is - // accepted for API consistency but not forwarded to SSE. - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), duration); - + if (scope === "global" && directory) throw new Error("Global event scope cannot be combined with directory"); + const context = createRequestContext({ signal: extra?.signal, timeout: durationMs ?? 3000 }); + let streamError: unknown; try { - for await (const evt of client.subscribeSSE("/event", { signal: controller.signal })) { + for await (const evt of client.subscribeSSE(scope === "global" ? "/global/event" : "/event", { + signal: context.signal, deadline: context.deadline, directory, + })) { events.push(evt); - if (events.length >= max) break; - if (controller.signal.aborted) break; + if (events.length >= (maxEvents ?? 50)) break; + } + } catch (error) { + // Only the polling window expiring normally is a successful empty + // observation. Caller cancellation and connection errors stay visible. + if (!(context.signal.aborted && context.signal.reason?.name === "TimeoutError" && !extra?.signal?.aborted)) { + streamError = error; } - } catch { - // SSE connection will error when aborted — that's expected } finally { - clearTimeout(timeout); + context.dispose(); } - - if (events.length === 0) { - return toolResult("No events received during the polling period."); + const formatted = events.map((e) => { + try { return `[${e.event}] ${JSON.stringify(JSON.parse(e.data), null, 2)}`; } + catch { return `[${e.event}] ${e.data}`; } + }).join("\n\n"); + if (streamError) { + const failure = toolError(streamError); + return { + ...failure, + content: [...failure.content, ...(events.length ? [{ type: "text" as const, text: `Collected ${events.length} partial event(s) before the stream stopped:\n\n${formatted}` }] : [])], + structuredContent: { events, partial: true, scope: scope ?? "project" }, + }; } - - const formatted = events - .map((e) => { - try { - const parsed = JSON.parse(e.data); - return `[${e.event}] ${JSON.stringify(parsed, null, 2)}`; - } catch { - return `[${e.event}] ${e.data}`; - } - }) - .join("\n\n"); - - return toolResult( - `Collected ${events.length} event(s):\n\n${formatted}`, - ); - } catch (e) { - return toolError(e); + return { + ...toolResult(events.length ? `Collected ${events.length} event(s):\n\n${formatted}` : "No events received during the polling period."), + structuredContent: { events, partial: false, scope: scope ?? "project" }, + }; + } catch (error) { + return toolError(error); } }, ); diff --git a/src/tools/file.ts b/src/tools/file.ts index 11019f7..004fb12 100644 --- a/src/tools/file.ts +++ b/src/tools/file.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; import { toolJson, toolError, toolResult, directoryParam, readOnly } from "../helpers.js"; @@ -19,7 +19,7 @@ export function registerFileTools(server: McpServer, client: OpenCodeClient) { const raw = await client.get("/find", { pattern }, directory); const results = Array.isArray(raw) ? raw as Array> : []; if (results.length === 0) { - return toolResult(`No matches found for pattern: ${pattern}`); + return toolResult(`No matches found for pattern: ${pattern}`, false, { data: [] }); } const formatted = results.map((r) => { // The API returns path as {text: "file.ts"} and lines as {text: "content\n"} @@ -36,7 +36,7 @@ export function registerFileTools(server: McpServer, client: OpenCodeClient) { : JSON.stringify(rawLines); return `${filePath}:${lineNum} ${lineText}`; }).join("\n"); - return toolResult(`${results.length} match(es):\n\n${formatted}`); + return toolResult(`${results.length} match(es):\n\n${formatted}`, false, { data: results }); } catch (e) { return toolError(e); } @@ -71,9 +71,9 @@ export function registerFileTools(server: McpServer, client: OpenCodeClient) { if (limit !== undefined) q.limit = String(limit); const files = (await client.get("/find/file", q, directory)) as string[]; if (!files || files.length === 0) { - return toolResult(`No files found matching: ${query}`); + return toolResult(`No files found matching: ${query}`, false, { data: [] }); } - return toolResult(files.join("\n")); + return toolResult(files.join("\n"), false, { data: files }); } catch (e) { return toolError(e); } @@ -93,17 +93,21 @@ export function registerFileTools(server: McpServer, client: OpenCodeClient) { const raw = await client.get("/find/symbol", { query }, directory); const symbols = Array.isArray(raw) ? raw as Array> : []; if (symbols.length === 0) { - return toolResult(`No symbols found matching: ${query}`); + return toolResult(`No symbols found matching: ${query}`, false, { data: [] }); } const lines = symbols.map((s) => { const name = s.name ?? s.symbol ?? "?"; const kind = s.kind ? ` (${s.kind})` : ""; - const loc = s.location ?? s.path ?? s.file ?? ""; - const line = s.line ?? s.lineNumber ?? ""; + const location = s.location && typeof s.location === "object" + ? s.location as { uri?: string; range?: { start?: { line?: number } } } + : undefined; + const loc = location?.uri ?? (typeof s.location === "string" ? s.location : s.path ?? s.file ?? ""); + const line = typeof location?.range?.start?.line === "number" + ? location.range.start.line + 1 : s.line ?? s.lineNumber ?? ""; const locStr = loc ? ` — ${loc}${line ? `:${line}` : ""}` : ""; return `- ${name}${kind}${locStr}`; }); - return toolResult(`${symbols.length} symbol(s) matching "${query}":\n\n${lines.join("\n")}`); + return toolResult(`${symbols.length} symbol(s) matching "${query}":\n\n${lines.join("\n")}`, false, { data: symbols }); } catch (e) { return toolError(e); } @@ -151,7 +155,7 @@ export function registerFileTools(server: McpServer, client: OpenCodeClient) { try { const result = (await client.get("/file/content", { path }, directory)) as Record; if (typeof result.content === "string") { - return toolResult(`File: ${path}\n\n${result.content}`); + return toolResult(`File: ${path}\n\n${result.content}`, false, { data: result }); } return toolJson(result); } catch (e) { diff --git a/src/tools/global.ts b/src/tools/global.ts index a15a655..0d8eaf8 100644 --- a/src/tools/global.ts +++ b/src/tools/global.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; import { toolResult, toolError, directoryParam, readOnly } from "../helpers.js"; diff --git a/src/tools/input.ts b/src/tools/input.ts new file mode 100644 index 0000000..9e81f90 --- /dev/null +++ b/src/tools/input.ts @@ -0,0 +1,63 @@ +import { inputRequired } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { McpServer } from "../mcp-server.js"; +import { OpenCodeClient } from "../client.js"; +import { JobService } from "../jobs.js"; +import { directoryParam, normalizeDirectory, toolJson, toolResult } from "../helpers.js"; +import { decodeInputResponses, inputAnswerSchema, inputRequests, supportsForm } from "../task-input.js"; + +const errorResult = (error: unknown) => toolResult(error instanceof Error ? error.message : String(error), true); + +export function registerInputTools(server: McpServer, client: OpenCodeClient, jobs: JobService) { + server.tool("opencode_question_list", "List pending OpenCode questions, optionally filtered to a session.", { + sessionId: z.string().optional(), directory: directoryParam, + }, async ({ sessionId, directory }) => { + try { + const questions = await client.get("/question", undefined, normalizeDirectory(directory)); + return toolJson(sessionId ? questions.filter(q => q.sessionID === sessionId) : questions); + } catch (error) { return errorResult(error); } + }); + server.tool("opencode_question_reply", "Answer an OpenCode question request. Supply one array of selected labels or free text per question.", { + requestId: z.string().min(1), answers: z.array(z.array(z.string())), directory: directoryParam, + }, async ({ requestId, answers, directory }) => { + try { return toolJson(await client.post(`/question/${encodeURIComponent(requestId)}/reply`, { answers }, { directory: normalizeDirectory(directory) })); } + catch (error) { return errorResult(error); } + }); + server.tool("opencode_question_reject", "Reject a pending OpenCode question explicitly.", { + requestId: z.string().min(1), directory: directoryParam, + }, async ({ requestId, directory }) => { + try { return toolJson(await client.post(`/question/${encodeURIComponent(requestId)}/reject`, undefined, { directory: normalizeDirectory(directory) })); } + catch (error) { return errorResult(error); } + }); + for (const action of ["get", "cancel"] as const) { + server.tool(`opencode_job_${action}`, action === "get" ? "Observe a durable job by ID, including pending inputs and final results." : "Explicitly abort the OpenCode session owned by a job. This also stops other work in that session.", { + jobId: z.string().min(1), + }, async ({ jobId }, extra) => { + try { + const job = await jobs[action](jobId, { signal: extra?.signal }); + return toolResult(job.text ?? `Job ${jobId}: ${job.status}`, job.status === "failed", { ...job }); + } catch (error) { return toolResult(error instanceof Error ? error.message : String(error), true, { jobId, status: "unknown" }); } + }); + } + server.tool("opencode_job_list", "List locally retained jobs in this OpenCode server and credential scope.", { + limit: z.number().int().positive().max(200).optional(), + }, async ({ limit }) => { + try { return toolJson(await jobs.list(limit)); } catch (error) { return errorResult(error); } + }); + server.tool("opencode_job_input", "Respond to a job's pending questions or permissions. Omit responses to request MCP forms when supported, or receive manual response instructions. Never approves automatically.", { + jobId: z.string().min(1), responses: z.array(inputAnswerSchema).optional(), + }, async ({ jobId, responses }, extra) => { + try { + const options = { signal: extra?.signal }; + let job = await jobs.get(jobId, options); + const input = responses ?? (extra?.mcpReq?.inputResponses ? decodeInputResponses(job.inputs ?? [], extra.mcpReq.inputResponses) : undefined); + if (input?.length) job = await jobs.update(jobId, input, options); + if (job.status === "input_required" && job.inputs?.length) { + const envelope = extra?.mcpReq?.envelope as Record | undefined; + if (supportsForm(envelope?.["io.modelcontextprotocol/clientCapabilities"])) return inputRequired({ inputRequests: inputRequests(job.inputs) }); + return toolResult("OpenCode needs input. Call opencode_job_input with explicit responses for the pending IDs.", false, { ...job }); + } + return toolResult(job.text ?? `Job ${jobId}: ${job.status}`, job.status === "failed", { ...job }); + } catch (error) { return errorResult(error); } + }); +} diff --git a/src/tools/message.ts b/src/tools/message.ts index 502c355..d15e517 100644 --- a/src/tools/message.ts +++ b/src/tools/message.ts @@ -1,6 +1,7 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; +import { createMessageId } from "../jobs.js"; import { toolResult, toolError, @@ -9,6 +10,7 @@ import { formatMessageList, applyModelDefaults, directoryParam, + outputFormatParam, } from "../helpers.js"; export function registerMessageTools( @@ -35,7 +37,7 @@ export function registerMessageTools( query, directory, ); - return toolResult(formatMessageList(messages as unknown[])); + return toolResult(formatMessageList(messages as unknown[]), false, { data: messages }); } catch (e) { return toolError(e); } @@ -57,7 +59,7 @@ export function registerMessageTools( undefined, directory, ); - return toolResult(formatMessageResponse(msg)); + return toolResult(formatMessageResponse(msg), false, { data: msg }); } catch (e) { return toolError(e); } @@ -87,6 +89,7 @@ export function registerMessageTools( "If true, inject context without triggering AI response (useful for plugins)", ), system: z.string().optional().describe("System prompt override"), + format: outputFormatParam, directory: directoryParam, }, async ({ @@ -98,6 +101,7 @@ export function registerMessageTools( agent, noReply, system, + format, directory, }) => { try { @@ -110,6 +114,7 @@ export function registerMessageTools( if (agent) body.agent = agent; if (noReply !== undefined) body.noReply = noReply; if (system) body.system = system; + if (format) body.format = format; const response = await client.post( `/session/${sessionId}/message`, body, @@ -126,6 +131,7 @@ export function registerMessageTools( return toolResult( parts.join("\n\n") || "Empty response.", analysis.hasError, + { data: response }, ); } catch (e) { return toolError(e); @@ -135,7 +141,7 @@ export function registerMessageTools( server.tool( "opencode_message_send_async", - "Send a prompt message asynchronously (fire-and-forget, does not wait for response). Use opencode_wait to poll for completion.", + "Send a prompt asynchronously and return its messageId. Pass sessionId and messageId to opencode_wait to observe this exact turn.", { sessionId: z.string().describe("Session ID"), text: z.string().describe("The text message to send"), @@ -149,23 +155,29 @@ export function registerMessageTools( .describe("Model ID (e.g. 'claude-3-5-sonnet-20241022')"), variant: z.string().optional().describe("Model variant (e.g. 'fast', 'smart')"), agent: z.string().optional().describe("Agent to use"), + format: outputFormatParam, directory: directoryParam, }, - async ({ sessionId, text, providerID, modelID, variant, agent, directory }) => { + async ({ sessionId, text, providerID, modelID, variant, agent, format, directory }) => { + const messageId = createMessageId(); try { const body: Record = { + messageID: messageId, parts: [{ type: "text", text }], }; const model = applyModelDefaults(providerID, modelID); if (model) body.model = model; if (variant) body.variant = variant; if (agent) body.agent = agent; + if (format) body.format = format; await client.post(`/session/${sessionId}/prompt_async`, body, { directory }); return toolResult( - "Message sent asynchronously. Use opencode_wait or opencode_message_list to check for responses.", + `Message sent asynchronously. Use opencode_wait({sessionId: "${sessionId}", messageId: "${messageId}"}) to observe this exact turn.`, + false, + { data: { sessionId, messageId, status: "submitted" } }, ); } catch (e) { - return toolError(e); + return { ...toolError(e), structuredContent: { data: { sessionId, messageId } } }; } }, ); @@ -212,7 +224,7 @@ export function registerMessageTools( body, { directory }, ); - return toolResult(formatMessageResponse(result)); + return toolResult(formatMessageResponse(result), false, { data: result }); } catch (e) { return toolError(e); } @@ -240,7 +252,7 @@ export function registerMessageTools( body, { directory }, ); - return toolResult(formatMessageResponse(result)); + return toolResult(formatMessageResponse(result), false, { data: result }); } catch (e) { return toolError(e); } diff --git a/src/tools/misc.ts b/src/tools/misc.ts index 9af1e45..557b759 100644 --- a/src/tools/misc.ts +++ b/src/tools/misc.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; import { toolJson, toolError, toolResult, directoryParam, destructive, readOnly } from "../helpers.js"; diff --git a/src/tools/project.ts b/src/tools/project.ts index 7fa2e69..035ebde 100644 --- a/src/tools/project.ts +++ b/src/tools/project.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; import { toolJson, toolError, toolResult, directoryParam } from "../helpers.js"; import { z } from "zod"; diff --git a/src/tools/provider.ts b/src/tools/provider.ts index f6e6225..10f5ad3 100644 --- a/src/tools/provider.ts +++ b/src/tools/provider.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; import { toolJson, toolError, toolResult, directoryParam, isProviderConfigured } from "../helpers.js"; @@ -165,14 +165,14 @@ export function registerProviderTools( } const lines = entries.map(([providerId, methods]) => { const arr = Array.isArray(methods) ? methods as Array> : []; - const methodDescs = arr.map((m) => { + const methodDescs = arr.map((m, index) => { const type = m.type ?? "?"; const label = m.label ?? ""; - return label ? `${type} (${label})` : String(type); + return `[${index}] ${label ? `${type} (${label})` : String(type)}`; }); return `- ${providerId}: ${methodDescs.join(", ") || "none"}`; }); - return toolResult(`## Auth Methods (${entries.length} providers)\n${lines.join("\n")}`); + return toolResult(`## Auth Methods (${entries.length} providers)\n${lines.join("\n")}`, false, { data: raw }); } return toolJson(raw); } catch (e) { @@ -189,11 +189,13 @@ export function registerProviderTools( "Start OAuth authorization for a provider", { providerId: z.string().describe("Provider ID to authorize"), + method: z.number().int().nonnegative().default(0).describe("Auth method index from opencode_provider_auth_methods (default 0)"), + inputs: z.record(z.string(), z.string()).optional().describe("Additional inputs requested by the selected authentication method"), }, - async ({ providerId }) => { + async ({ providerId, method, inputs }) => { try { return toolJson( - await client.post(`/provider/${providerId}/oauth/authorize`), + await client.post(`/provider/${encodeURIComponent(providerId)}/oauth/authorize`, { method: method ?? 0, ...(inputs ? { inputs } : {}) }), ); } catch (e) { return toolError(e); @@ -206,14 +208,15 @@ export function registerProviderTools( "Handle OAuth callback for a provider", { providerId: z.string().describe("Provider ID"), - callbackData: z - .record(z.string(), z.unknown()) - .describe("OAuth callback data"), + callbackData: z.object({ + method: z.number().int().nonnegative().describe("The method index used for authorization"), + code: z.string().optional().describe("Authorization code for methods that request one"), + }).describe("OAuth callback data"), }, async ({ providerId, callbackData }) => { try { await client.post( - `/provider/${providerId}/oauth/callback`, + `/provider/${encodeURIComponent(providerId)}/oauth/callback`, callbackData, ); return toolResult("OAuth callback processed."); diff --git a/src/tools/session.ts b/src/tools/session.ts index 04fe6b4..2c69d21 100644 --- a/src/tools/session.ts +++ b/src/tools/session.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; import { toolError, formatSessionList, formatDiffResponse, resolveSessionStatus, toolResult, directoryParam, destructive, readOnly } from "../helpers.js"; @@ -51,7 +51,7 @@ export function registerSessionTools( async ({ directory }) => { try { const sessions = (await client.get("/session", undefined, directory)) as Array>; - return toolResult(formatSessionList(sessions)); + return toolResult(formatSessionList(sessions), false, { data: sessions }); } catch (e) { return toolError(e); } @@ -71,8 +71,9 @@ export function registerSessionTools( const body: Record = {}; if (parentID) body.parentID = parentID; if (title) body.title = title; - const session = await client.post("/session", body, { directory }); - return toolResult(`Session created.\n\n${formatSession(session)}`); + const session = await client.post("/session", body, { directory }) as Record; + if (typeof session?.id !== "string") throw new Error("OpenCode returned a session without an ID"); + return toolResult(`Session created.\n\n${formatSession(session)}`, false, { sessionId: session.id, session }); } catch (e) { return toolError(e); } @@ -90,7 +91,7 @@ export function registerSessionTools( async ({ id, directory }) => { try { const session = await client.get(`/session/${id}`, undefined, directory); - return toolResult(formatSession(session)); + return toolResult(formatSession(session), false, { data: session }); } catch (e) { return toolError(e); } @@ -171,10 +172,10 @@ export function registerSessionTools( : {}; const entries = Object.entries(statuses); if (entries.length === 0) { - return toolResult("All sessions idle."); + return toolResult("All sessions idle.", false, { data: statuses }); } const lines = entries.map(([id, status]) => `- ${id}: ${resolveSessionStatus(status)}`); - return toolResult(`## Session Status (${entries.length})\n${lines.join("\n")}`); + return toolResult(`## Session Status (${entries.length})\n${lines.join("\n")}`, false, { data: statuses }); } catch (e) { return toolError(e); } @@ -467,7 +468,7 @@ export function registerSessionTools( try { const sessions = (await client.get("/session", undefined, directory)) as Array>; if (!sessions || sessions.length === 0) { - return toolResult("No sessions found."); + return toolResult("No sessions found.", false, { data: [] }); } const q = query.toLowerCase(); @@ -478,11 +479,11 @@ export function registerSessionTools( }); if (matches.length === 0) { - return toolResult(`No sessions matching: "${query}"\n\nTotal sessions: ${sessions.length}. Use \`opencode_session_list\` to see all.`); + return toolResult(`No sessions matching: "${query}"\n\nTotal sessions: ${sessions.length}. Use \`opencode_session_list\` to see all.`, false, { data: [] }); } return toolResult( - `## Sessions matching "${query}" (${matches.length}/${sessions.length})\n${formatSessionList(matches)}`, + `## Sessions matching "${query}" (${matches.length}/${sessions.length})\n${formatSessionList(matches)}`, false, { data: matches }, ); } catch (e) { return toolError(e); diff --git a/src/tools/tui.ts b/src/tools/tui.ts index 942539a..f06a925 100644 --- a/src/tools/tui.ts +++ b/src/tools/tui.ts @@ -4,7 +4,7 @@ */ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; import { toolResult, toolError, directoryParam } from "../helpers.js"; diff --git a/src/tools/workflow.ts b/src/tools/workflow.ts index c3ab22f..dfa2a0c 100644 --- a/src/tools/workflow.ts +++ b/src/tools/workflow.ts @@ -4,8 +4,11 @@ */ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, type ToolContext } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; +import { JobService, observeSession, type JobSnapshot } from "../jobs.js"; +import { createRequestContext, abortableSleep, withAbort } from "../async.js"; +import { dispatchShape, runShape } from "../job-contract.js"; import { formatMessageResponse, formatMessageList, @@ -20,12 +23,53 @@ import { toolError, directoryParam, readOnly, + outputFormatParam, } from "../helpers.js"; +function jobResult(snapshot: JobSnapshot, note = "", extra: Record = {}) { + const lines = [snapshot.directory ? `Directory: ${snapshot.directory}` : "", snapshot.sessionId ? `Session: ${snapshot.sessionId}` : "", + snapshot.jobId ? `Job: ${snapshot.jobId}` : "", `Status: ${snapshot.status}`].filter(Boolean); + if (snapshot.status === "completed") lines.push("Session completed."); + if (snapshot.status === "input_required") lines.push("Input required. Inspect pending permissions and questions before continuing."); + if (snapshot.error) lines.push(`Error: ${typeof snapshot.error === "string" ? snapshot.error : JSON.stringify(snapshot.error)}`); + if (note) lines.push(note); + if (snapshot.text) lines.push(snapshot.text); + return toolResult(lines.join("\n\n"), snapshot.status === "failed", { ...snapshot, ...extra }); +} + +/** A wait observes a remote job; cancelling observation must not abort that job. */ +async function waitForSnapshot( + read: (options: { signal: AbortSignal; deadline: number }) => Promise, + initial: JobSnapshot, seconds: number, interval: number, extra?: ToolContext, +) { + let snapshot = initial; + const timeoutNote = "Timeout: session still running or its outcome is unknown. Use opencode_check or opencode_conversation to check progress, opencode_wait to wait longer, or opencode_session_abort to stop it."; + if (seconds === 0) return jobResult(snapshot, timeoutNote, { timedOut: true }); + const context = createRequestContext({ signal: extra?.signal, timeout: seconds * 1000 }); + try { + while (true) { + snapshot = await withAbort(read(context), context.signal); + if (["completed", "failed", "cancelled", "input_required"].includes(snapshot.status)) return jobResult(snapshot); + await abortableSleep(Math.min(interval, Math.max(0, context.deadline - Date.now())), context.signal); + } + } catch (error) { + if (context.signal.aborted) { + if (extra?.signal?.aborted) return jobResult(snapshot, "Observation cancelled. The remote session may still be running.", { observationCancelled: true }); + return jobResult(snapshot, timeoutNote, { timedOut: true }); + } + return jobResult({ ...snapshot, status: "unknown", error: error instanceof Error ? error.message : String(error) }); + } finally { + context.dispose(); + } +} + export function registerWorkflowTools( server: McpServer, client: OpenCodeClient, + jobs?: JobService, ) { + // Standalone registration (including unit clients) never writes the user's store. + const service = jobs ?? new JobService(client, { storeRoot: null }); // ─── Setup / onboarding ─────────────────────────────────────────── server.tool( "opencode_setup", @@ -200,7 +244,7 @@ export function registerWorkflowTools( tips.push("**You need to configure a provider first.** Options:"); tips.push("1. Set an API key: `opencode_auth_set` with providerId (e.g. 'anthropic', 'openai', 'google')"); tips.push("2. Set an env var (e.g. `OPENROUTER_API_KEY`, `HF_TOKEN`, `ANTHROPIC_API_KEY`) and restart opencode"); - tips.push("3. Try **firmware** — it has 23 free models, no API key needed. Use `opencode_ask` with `providerID: 'firmware'`"); + tips.push("3. Use `opencode_provider_models` to inspect available models and choose one supported by your configured provider."); } else { // Providers ready — guide to first task tips.push("**You're ready to go!** Try:"); @@ -247,15 +291,18 @@ export function registerWorkflowTools( .string() .optional() .describe("Optional system prompt override"), + format: outputFormatParam, directory: directoryParam, }, - async ({ prompt, title, providerID, modelID, variant, agent, system, directory }) => { + async ({ prompt, title, providerID, modelID, variant, agent, system, format, directory }, extra) => { + let sessionId: string | undefined; try { // 1. Create session const session = (await client.post("/session", { title: title ?? prompt.slice(0, 80), - }, { directory })) as Record; - const sessionId = session.id as string; + }, { directory, ...(extra?.signal ? { signal: extra.signal } : {}) })) as Record; + sessionId = session.id as string; + if (!sessionId) throw new Error("OpenCode returned a session without an ID"); // 2. Send prompt const body: Record = { @@ -266,11 +313,12 @@ export function registerWorkflowTools( if (variant) body.variant = variant; if (agent) body.agent = agent; if (system) body.system = system; + if (format) body.format = format; const response = await client.post( `/session/${sessionId}/message`, body, - { directory }, + { directory, ...(extra?.signal ? { signal: extra.signal } : {}) }, ); // 3. Analyze for auth / empty response issues @@ -284,9 +332,10 @@ export function registerWorkflowTools( if (analysis.warning) { parts.push(`\n--- WARNING ---\n${analysis.warning}`); } - return toolResult(parts.join("\n\n"), analysis.hasError); + return toolResult(parts.join("\n\n"), analysis.hasError, { sessionId, directory, data: response }); } catch (e) { - return toolError(e); + const result = toolError(e); + return { ...result, content: [{ type: "text" as const, text: `${sessionId ? `Session: ${sessionId}\n` : ""}${result.content[0].text}` }], structuredContent: { sessionId, directory } }; } }, ); @@ -302,9 +351,10 @@ export function registerWorkflowTools( modelID: z.string().optional().describe("Model ID"), variant: z.string().optional().describe("Model variant"), agent: z.string().optional().describe("Agent to use"), + format: outputFormatParam, directory: directoryParam, }, - async ({ sessionId, prompt, providerID, modelID, variant, agent, directory }) => { + async ({ sessionId, prompt, providerID, modelID, variant, agent, format, directory }, extra) => { try { const body: Record = { parts: [{ type: "text", text: prompt }], @@ -313,11 +363,12 @@ export function registerWorkflowTools( if (model) body.model = model; if (variant) body.variant = variant; if (agent) body.agent = agent; + if (format) body.format = format; const response = await client.post( `/session/${sessionId}/message`, body, - { directory }, + { directory, ...(extra?.signal ? { signal: extra.signal } : {}) }, ); const analysis = analyzeMessageResponse(response); @@ -334,9 +385,11 @@ export function registerWorkflowTools( return toolResult( parts.join("\n\n") || "Empty response.", analysis.hasError, + { sessionId, directory, data: response }, ); } catch (e) { - return toolError(e); + const result = toolError(e); + return { ...result, content: [{ type: "text" as const, text: `Session: ${sessionId}\n${result.content[0].text}` }], structuredContent: { sessionId, directory } }; } }, ); @@ -366,7 +419,7 @@ export function registerWorkflowTools( const formatted = formatMessageList( messages as unknown[], ); - return toolResult(formatted); + return toolResult(formatted, false, { sessionId, data: messages }); } catch (e) { return toolError(e); } @@ -495,65 +548,31 @@ export function registerWorkflowTools( }, ); - // ─── Wait for async session to complete ─────────────────────────── + // ─── Wait for an observed session or durable job ─────────────────── server.tool( "opencode_wait", - "Poll a session until it finishes processing. Use after opencode_message_send_async to wait for the AI to complete its response. Sends progress notifications while waiting. If timeout is reached, returns a progress report (not an error). For long tasks, consider using opencode_session_todo to check progress instead of blocking.", + "Wait for a session or durable job. Returns completed, failed, input_required, or a resumable timeout. Cancelling this wait stops observation; use job_cancel or session_abort to stop OpenCode.", { - sessionId: z.string().describe("Session ID to wait on"), - timeoutSeconds: z - .number() - .optional() - .describe("Max seconds to wait (default: 120). Set higher (300-600) for complex tasks."), - pollIntervalMs: z - .number() - .optional() - .describe("Polling interval in ms (default: 2000)"), + sessionId: z.string().optional().describe("Session ID (required unless jobId is supplied)"), + jobId: z.string().optional().describe("Durable job ID returned by opencode_fire or opencode_run"), + messageId: z.string().optional().describe("Submitted user message ID to correlate the exact response"), + timeoutSeconds: z.number().nonnegative().max(86400).optional().describe("Maximum seconds to wait (default 120); timeout is resumable"), + pollIntervalMs: z.number().int().min(10).max(60000).optional().describe("Polling interval in milliseconds (default 2000)"), directory: directoryParam, }, - async ({ sessionId, timeoutSeconds, pollIntervalMs, directory }) => { + readOnly, + async ({ sessionId, jobId, messageId, timeoutSeconds, pollIntervalMs, directory }, extra) => { try { - const timeout = (timeoutSeconds ?? 120) * 1000; - const interval = pollIntervalMs ?? 2000; - const start = Date.now(); - - while (Date.now() - start < timeout) { - const statuses = (await client.get("/session/status", undefined, directory)) as Record< - string, - unknown - >; - const status = resolveSessionStatus(statuses[sessionId]); - - if (status === "idle" || status === "completed") { - // Fetch latest messages - const messages = await client.get( - `/session/${sessionId}/message`, - { limit: "1" }, - directory, - ); - const arr = messages as unknown[]; - if (arr.length > 0) { - return toolResult( - `Session completed.\n\n${formatMessageResponse(arr[arr.length - 1])}`, - ); - } - return toolResult("Session completed (no messages)."); - } - - if (status === "error") { - return toolResult(`Session ended with error status.`, true); - } - - await new Promise((r) => setTimeout(r, interval)); - } - - return toolResult( - `Timeout: session still processing after ${timeoutSeconds ?? 120}s. ` + - `Use \`opencode_conversation\` to check progress, or \`opencode_session_abort\` to stop it.`, - true, + directory = normalizeDirectory(directory); + if (!sessionId && !jobId) throw new Error("Provide sessionId or jobId to wait for a task."); + const initial: JobSnapshot = { sessionId: sessionId ?? "", jobId, messageId, directory, status: "unknown" }; + return await waitForSnapshot( + options => jobId ? service.get(jobId, options) : observeSession(client, sessionId!, directory, messageId, options), + initial, timeoutSeconds ?? 120, pollIntervalMs ?? 2000, extra, ); - } catch (e) { - return toolError(e); + } catch (error) { + const result = toolError(error); + return { ...result, structuredContent: { sessionId, jobId, messageId, directory, status: "unknown" } }; } }, ); @@ -590,35 +609,44 @@ export function registerWorkflowTools( "Quick-test whether a provider is working. Creates a temporary session, sends a trivial prompt, checks the response, and cleans up. Great for debugging auth issues.", { providerId: z.string().describe("Provider ID to test (e.g. 'anthropic', 'openrouter')"), - modelID: z.string().optional().describe("Specific model ID to test. If omitted, uses provider default."), + modelID: z.string().optional().describe("Specific model ID to test. If omitted, discovers the provider default or first available model."), variant: z.string().optional().describe("Model variant"), + format: outputFormatParam, directory: directoryParam, }, - async ({ providerId, modelID, variant, directory }) => { + async ({ providerId, modelID, variant, format, directory }, extra) => { let sessionId: string | null = null; try { + if (!modelID) { + const raw = await client.get("/provider", undefined, directory) as Record; + const providers = Array.isArray(raw) ? raw : Array.isArray(raw?.all) ? raw.all : []; + const provider = providers.find((p: Record) => p.id === providerId); + const models = provider?.models; + const choices = Array.isArray(models) + ? models.map((m: Record) => m.id).filter((id: unknown): id is string => typeof id === "string") + : models && typeof models === "object" ? Object.keys(models) : []; + const preferred = (raw?.default as Record | undefined)?.[providerId]; + modelID = preferred && choices.includes(preferred) ? preferred : choices[0]; + if (!modelID) throw new Error(`No model discovered for provider "${providerId}". Specify modelID from opencode_provider_models.`); + } // 1. Create a temporary test session const session = (await client.post("/session", { title: `[test] ${providerId}`, - }, { directory })) as Record; + }, { directory, ...(extra?.signal ? { signal: extra.signal } : {}) })) as Record; sessionId = session.id as string; // 2. Send a trivial prompt const body: Record = { parts: [{ type: "text", text: "Say hello in one word." }], }; - // Use the specified model, or let the provider pick its default - if (modelID) { - body.model = { providerID: providerId, modelID }; - } else { - body.providerID = providerId; - } + body.model = { providerID: providerId, modelID }; if (variant) body.variant = variant; + if (format) body.format = format; const response = await client.post( `/session/${sessionId}/message`, body, - { directory }, + { directory, ...(extra?.signal ? { signal: extra.signal } : {}) }, ); // 3. Analyze the response @@ -635,12 +663,15 @@ export function registerWorkflowTools( return toolResult( `Provider "${providerId}" FAILED.\n\n${reason}`, true, + { sessionId, directory, providerId, modelID, data: response }, ); } const preview = formatted.length > 200 ? formatted.slice(0, 200) + "..." : formatted; return toolResult( `Provider "${providerId}" is working.\n\nResponse: ${preview}`, + false, + { sessionId, directory, providerId, modelID, data: response }, ); } catch (e) { // Cleanup on error @@ -649,273 +680,91 @@ export function registerWorkflowTools( await client.delete(`/session/${sessionId}`, undefined, directory); } catch { /* best-effort cleanup */ } } - return toolError(e); + return { ...toolError(e), structuredContent: { sessionId, directory, providerId, modelID } }; } }, ); - // ─── Run: create session + async send + poll until done ────────── + // ─── Durable dispatch and observation ───────────────────────────── server.tool( "opencode_run", - "Send a task to OpenCode and wait for completion. Combines session creation, async prompt, and polling into a single tool call. Use this instead of the manual opencode_message_send_async + opencode_wait pattern.", - { - prompt: z.string().describe("The task or instruction to send"), - sessionId: z - .string() - .optional() - .describe("Existing session ID to continue (omit to create a new session)"), - title: z.string().optional().describe("Session title (only for new sessions)"), - providerID: z.string().optional().describe("Provider ID (e.g. 'anthropic')"), - modelID: z.string().optional().describe("Model ID (e.g. 'claude-opus-4-6')"), - variant: z.string().optional().describe("Model variant"), - agent: z.string().optional().describe("Agent to use"), - maxDurationSeconds: z - .number() - .optional() - .describe("Max seconds to wait for completion (default: 600 = 10 minutes)"), - directory: directoryParam, - }, - async ({ prompt, sessionId, title, providerID, modelID, variant, agent, maxDurationSeconds, directory }) => { + "Send a task and wait for its correlated response. Returns a durable job ID; an observation timeout leaves the remote job running and can be resumed with opencode_wait.", + runShape, + async ({ prompt, sessionId, title, providerID, modelID, variant, agent, format, maxDurationSeconds, directory }, extra) => { + let snapshot: JobSnapshot = { sessionId: sessionId ?? "", directory, status: "unknown" }; + const seconds = maxDurationSeconds ?? 600; + const context = createRequestContext({ signal: extra?.signal, timeout: seconds > 0 ? seconds * 1000 : 120000 }); try { - // 1. Create or reuse session - let sid = sessionId; - if (!sid) { - const session = (await client.post("/session", { - title: title ?? prompt.slice(0, 80), - }, { directory })) as Record; - sid = session.id as string; - } - - // 2. Send async (fire-and-forget) - const body: Record = { - parts: [{ type: "text", text: prompt }], - noReply: false, - }; - const model = applyModelDefaults(providerID, modelID); - if (model) body.model = model; - if (variant) body.variant = variant; - if (agent) body.agent = agent; - - await client.post(`/session/${sid}/prompt_async`, body, { directory }); - - // Session-directory consistency note - const dirNote = sessionId && directory - ? `\n_Note: Using session ${sid} in directory ${directory}. Ensure this session belongs to this project._` - : ""; - - // 3. Poll until done - const timeout = (maxDurationSeconds ?? 600) * 1000; - const interval = 3000; - const start = Date.now(); - const dirLabel = directory ? `Directory: ${directory}\n` : ""; - - while (Date.now() - start < timeout) { - await new Promise((r) => setTimeout(r, interval)); - - const statuses = (await client.get("/session/status", undefined, directory)) as Record; - const status = resolveSessionStatus(statuses[sid!]); - - if (status === "idle" || status === "completed") { - // Get final response - const messages = await client.get( - `/session/${sid}/message`, - { limit: "1" }, - directory, - ); - const arr = messages as unknown[]; - const lastMsg = arr.length > 0 ? formatMessageResponse(arr[arr.length - 1]) : ""; - - // Get todo summary - let todoSummary = ""; - try { - const todos = await client.get(`/session/${sid}/todo`, undefined, directory); - if (Array.isArray(todos) && todos.length > 0) { - const completed = todos.filter((t: any) => t.status === "completed").length; - todoSummary = `\nTasks: ${completed}/${todos.length} completed`; - } - } catch { /* non-critical */ } - - return toolResult( - `${dirLabel}Session: ${sid}\nStatus: completed${todoSummary}${dirNote}\n\n${lastMsg || "(no response text)"}`, - ); - } - - if (status === "error") { - return toolResult( - `${dirLabel}Session: ${sid}\nStatus: error${dirNote}\n\nThe session ended with an error. Use \`opencode_conversation({sessionId: "${sid}"})\` to see what happened.`, - true, - ); - } - } - - // Timeout — return progress report - let todoProgress = ""; - try { - const todos = await client.get(`/session/${sid}/todo`, undefined, directory); - if (Array.isArray(todos) && todos.length > 0) { - const completed = todos.filter((t: any) => t.status === "completed").length; - const inProgress = todos.filter((t: any) => t.status === "in_progress").length; - todoProgress = `\nTasks: ${completed}/${todos.length} completed, ${inProgress} in progress`; - } - } catch { /* non-critical */ } - - return toolResult( - `${dirLabel}Session: ${sid}\nStatus: still running after ${maxDurationSeconds ?? 600}s${todoProgress}${dirNote}\n\n` + - `The session is still working. Options:\n` + - `- \`opencode_check({sessionId: "${sid}"})\` — quick progress check\n` + - `- \`opencode_wait({sessionId: "${sid}", timeoutSeconds: 300})\` — wait longer\n` + - `- \`opencode_session_abort({id: "${sid}"})\` — stop the session`, - true, - ); - } catch (e) { - return toolError(e); + snapshot = await service.start({ prompt, sessionId, title, directory, + model: applyModelDefaults(providerID, modelID), variant, agent, format }, context); + if (["failed", "unknown", "cancelled", "input_required"].includes(snapshot.status)) return jobResult(snapshot); + const remaining = seconds === 0 ? 0 : Math.max(0, (context.deadline - Date.now()) / 1000); + return await waitForSnapshot(options => service.get(snapshot.jobId!, options), snapshot, remaining, 1000, extra); + } catch (error) { + if (context.signal.aborted) return jobResult(snapshot, "Observation ended before dispatch could be confirmed. Check the session before retrying.", { timedOut: !extra?.signal?.aborted, observationCancelled: extra?.signal?.aborted === true }); + return jobResult({ ...snapshot, status: "failed", error: error instanceof Error ? error.message : String(error) }); + } finally { + context.dispose(); } }, ); - // ─── Fire: send task and return immediately ──────────────────────── server.tool( "opencode_fire", - "Fire-and-forget: send a task to OpenCode and return immediately. OpenCode works autonomously in the background. Use `opencode_check` to check progress anytime. Best for long-running tasks when you want to do other work in parallel.", - { - prompt: z.string().describe("The task or instruction to send"), - sessionId: z - .string() - .optional() - .describe("Existing session ID to continue (omit to create a new session)"), - title: z.string().optional().describe("Session title (only for new sessions)"), - providerID: z.string().optional().describe("Provider ID (e.g. 'anthropic')"), - modelID: z.string().optional().describe("Model ID (e.g. 'claude-opus-4-6')"), - variant: z.string().optional().describe("Model variant"), - agent: z.string().optional().describe("Agent to use"), - directory: directoryParam, - }, - async ({ prompt, sessionId, title, providerID, modelID, variant, agent, directory }) => { + "Dispatch a durable background task and return its job, session and message IDs immediately. Use opencode_check or opencode_wait to observe; job_cancel stops the task.", + dispatchShape, + async ({ prompt, sessionId, title, providerID, modelID, variant, agent, format, directory }, extra) => { try { - // 1. Create or reuse session - let sid = sessionId; - if (!sid) { - const session = (await client.post("/session", { - title: title ?? prompt.slice(0, 80), - }, { directory })) as Record; - sid = session.id as string; - } - - // 2. Send async - const body: Record = { - parts: [{ type: "text", text: prompt }], - noReply: false, - }; - const model = applyModelDefaults(providerID, modelID); - if (model) body.model = model; - if (variant) body.variant = variant; - if (agent) body.agent = agent; - - await client.post(`/session/${sid}/prompt_async`, body, { directory }); - - const dirLabel = directory ? `Directory: ${directory}\n` : ""; - return toolResult( - `${dirLabel}Task dispatched to session: ${sid}\n\n` + - `OpenCode is now working autonomously. Use these tools to monitor:\n` + - `- \`opencode_check({sessionId: "${sid}"})\` — quick progress check\n` + - `- \`opencode_session_todo({id: "${sid}"})\` — see the agent's task list\n` + - `- \`opencode_wait({sessionId: "${sid}"})\` — block until done\n` + - `- \`opencode_review_changes({sessionId: "${sid}"})\` — see file changes after completion`, - ); - } catch (e) { - return toolError(e); + const snapshot = await service.start({ prompt, sessionId, title, directory, + model: applyModelDefaults(providerID, modelID), variant, agent, format }, { signal: extra?.signal }); + return jobResult(snapshot, snapshot.status === "accepted" + ? `Task dispatched. Use opencode_check({jobId: "${snapshot.jobId}"}) or opencode_wait({jobId: "${snapshot.jobId}"}) to monitor it.` : ""); + } catch (error) { + return jobResult({ sessionId: sessionId ?? "", directory, status: "failed", error: error instanceof Error ? error.message : String(error) }); } }, ); - // ─── Check: cheap progress report for a session ──────────────────── + // ─── Check: summary metadata, never full patches ──────────────────── server.tool( "opencode_check", - "Get a compact cached progress report for a session. Much cheaper than opencode_conversation or opencode_wait — returns status, todos, and file counts in a single call. Use this to monitor sessions launched with opencode_fire.", + "Get current task status, pending input, todos and file counts. Uses session summary metadata instead of fetching patches. Pass jobId for exact durable turn correlation; detailed includes response text.", { - sessionId: z.string().describe("Session ID to check"), - detailed: z - .boolean() - .optional() - .describe("If true, include the last message text (default: false)"), + sessionId: z.string().optional().describe("Session ID (required unless jobId is supplied)"), + jobId: z.string().optional().describe("Durable job ID"), + messageId: z.string().optional().describe("Submitted user message ID to correlate"), + detailed: z.boolean().optional().describe("Include response text (default false)"), directory: directoryParam, }, readOnly, - async ({ sessionId, detailed, directory }) => { + async ({ sessionId, jobId, messageId, detailed, directory }, extra) => { try { - // Validate directory early — before .catch(() => null) swallows the error - directory = normalizeDirectory(directory) as typeof directory; - - // Parallel fetch: status, todos, session info, optionally last message - const promises: Promise[] = [ - client.get("/session/status", undefined, directory), - client.get(`/session/${sessionId}/todo`, undefined, directory).catch(() => null), - client.get(`/session/${sessionId}`, undefined, directory).catch(() => null), - ]; - if (detailed) { - promises.push( - client.get(`/session/${sessionId}/message`, { limit: "1" }, directory).catch(() => null), - ); - } - - const [statuses, todos, sessionInfo, lastMessages] = await Promise.all(promises) as [ - Record, - Array> | null, - Record | null, - unknown[] | null, - ]; - - const status = resolveSessionStatus(statuses[sessionId]); + directory = normalizeDirectory(directory); + if (!sessionId && !jobId) throw new Error("Provide sessionId or jobId to check a task."); + const options = { signal: extra?.signal }; + const snapshot = jobId ? await service.get(jobId, options) : await observeSession(client, sessionId!, directory, messageId, options); + const sid = snapshot.sessionId; + if (!sid) return jobResult(snapshot, "Session creation could not be confirmed. Inspect this job before retrying."); + const project = snapshot.directory ?? directory; + const todos = await client.get(`/session/${sid}/todo`, undefined, project, options).catch(() => null) as Array> | null; + const session = snapshot.session as { title?: string; summary?: { files?: number } } | undefined; const lines: string[] = []; - - // Session title - const title = sessionInfo?.title ?? "(untitled)"; - lines.push(`## ${title} [${sessionId}]`); - lines.push(`Status: **${status}**`); - - // Todos - if (Array.isArray(todos) && todos.length > 0) { - const completed = todos.filter((t) => t.status === "completed").length; - const inProgress = todos.filter((t) => t.status === "in_progress").length; - const pending = todos.length - completed - inProgress; - lines.push(`Tasks: ${completed}/${todos.length} completed` + - (inProgress > 0 ? `, ${inProgress} in progress` : "") + - (pending > 0 ? `, ${pending} pending` : "")); - - // Show current task - const current = todos.find((t) => t.status === "in_progress"); - if (current) { - lines.push(`Current: ${current.content ?? current.title ?? "(unknown)"}`); - } + if (session?.title) lines.push(`Title: ${session.title}`); + if (Array.isArray(todos) && todos.length) { + const completed = todos.filter(t => t.status === "completed").length; + const inProgress = todos.filter(t => t.status === "in_progress").length; + lines.push(`Tasks: ${completed}/${todos.length} completed, ${inProgress} in progress`); + const current = todos.find(t => t.status === "in_progress"); + if (current) lines.push(`Current: ${current.content ?? current.title ?? "(unknown)"}`); } - - // File changes count (from diff endpoint) - try { - const diffs = await client.get(`/session/${sessionId}/diff`, undefined, directory) as unknown[]; - if (Array.isArray(diffs) && diffs.length > 0) { - lines.push(`Files changed: ${diffs.length}`); - } - } catch { /* non-critical */ } - - // Last message (if detailed) - if (detailed && Array.isArray(lastMessages) && lastMessages.length > 0) { - const lastMsg = formatMessageResponse(lastMessages[lastMessages.length - 1]); - if (lastMsg) { - const truncated = lastMsg.length > 500 ? lastMsg.slice(0, 497) + "..." : lastMsg; - lines.push(`\n### Last message\n${truncated}`); - } - } - - // Suggest next action based on status - if (status === "idle" || status === "completed") { - lines.push(`\nDone! Use \`opencode_review_changes({sessionId: "${sessionId}"})\` to see all changes.`); - } else if (status === "error") { - lines.push(`\nFailed. Use \`opencode_conversation({sessionId: "${sessionId}"})\` to see what went wrong.`); - } - - return toolResult(lines.join("\n")); - } catch (e) { - return toolError(e); + if (typeof session?.summary?.files === "number") lines.push(`Files changed: ${session.summary.files}`); + if (snapshot.status === "completed") lines.push("Done! Use opencode_review_changes to see changes."); + if (detailed && snapshot.text) lines.push(`### Last message\n${snapshot.text.slice(0, 500)}`); + // Avoid duplicating a potentially long final response in compact monitoring. + return jobResult({ ...snapshot, text: undefined, result: detailed ? snapshot.result : undefined }, lines.join("\n"), { todos }); + } catch (error) { + const result = toolError(error); + return { ...result, structuredContent: { sessionId, jobId, messageId, directory, status: "unknown" } }; } }, ); diff --git a/tests/contracts.test.ts b/tests/contracts.test.ts new file mode 100644 index 0000000..b42ee9a --- /dev/null +++ b/tests/contracts.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { McpServer, ResourceTemplate } from "../src/mcp-server.js"; +import { OpenCodeClient } from "../src/client.js"; +import { registerResources } from "../src/resources.js"; +import { registerConfigTools } from "../src/tools/config.js"; +import { registerFileTools } from "../src/tools/file.js"; +import { registerProviderTools } from "../src/tools/provider.js"; +import { registerMessageTools } from "../src/tools/message.js"; +import { registerSessionTools } from "../src/tools/session.js"; +import { analyzeMessageResponse, formatMessageResponse, redactSecrets, safeStringify, toolJson, toolResult } from "../src/helpers.js"; + +function capture() { + const tools = new Map(); + const resources = new Map(); + const server = { + tool: (name: string, _description: string, schema: z.ZodRawShape, ...rest: unknown[]) => { + tools.set(name, { schema, handler: rest.at(-1) as Function }); + }, + resource: (name: string, uri: string | ResourceTemplate, _options: unknown, handler: Function) => { + resources.set(name, { uri, handler }); + }, + } as unknown as McpServer; + const client = { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn() }; + for (const register of [registerConfigTools, registerFileTools, registerProviderTools, registerMessageTools, registerSessionTools]) { + register(server, client as unknown as OpenCodeClient); + } + registerResources(server, client as unknown as OpenCodeClient); + const call = (name: string, input: unknown) => { + const tool = tools.get(name)!; + return tool.handler(z.object(tool.schema).parse(input)); + }; + return { tools, resources, client, call }; +} + +describe("provider and configuration contracts", () => { + it("sanitizes resource and config-update output, including short passwords", async () => { + const { resources, client, call } = capture(); + const config = { provider: { example: { options: { apiKey: "synthetic-private-credential", password: "tiny" } } } }; + client.get.mockResolvedValue(config); + client.patch.mockResolvedValue(config); + for (const name of ["config", "providers"]) { + const result = await resources.get(name)!.handler(); + expect(result.contents[0].text).not.toContain("synthetic-private-credential"); + expect(result.contents[0].text).not.toContain('"tiny"'); + } + const result = await call("opencode_config_update", { config: { theme: "dark" } }); + expect(result.structuredContent.data).toEqual(redactSecrets(config)); + expect(JSON.stringify(result)).not.toContain('"tiny"'); + }); + + it("sends OAuth method and additional inputs, and validates the callback", async () => { + const { client, call } = capture(); + client.post.mockResolvedValue({ url: "https://example.invalid/authorize", method: "code" }); + await call("opencode_provider_oauth_authorize", { providerId: "example", method: 2, inputs: { organization: "test" } }); + expect(client.post).toHaveBeenLastCalledWith("/provider/example/oauth/authorize", { method: 2, inputs: { organization: "test" } }); + await call("opencode_provider_oauth_authorize", { providerId: "example" }); + expect(client.post).toHaveBeenLastCalledWith("/provider/example/oauth/authorize", { method: 0 }); + await call("opencode_provider_oauth_callback", { providerId: "example", callbackData: { method: 2, code: "synthetic-code" } }); + expect(client.post).toHaveBeenLastCalledWith("/provider/example/oauth/callback", { method: 2, code: "synthetic-code" }); + expect(() => call("opencode_provider_oauth_callback", { providerId: "example", callbackData: { method: -1 } })).toThrow(); + }); + + it("includes method indices and input metadata in discovery", async () => { + const { client, call } = capture(); + const data = { example: [{ type: "oauth", label: "Personal", prompts: [{ key: "organization", type: "text" }] }] }; + client.get.mockResolvedValue(data); + const result = await call("opencode_provider_auth_methods", {}); + expect(result.content[0].text).toContain("[0] oauth (Personal)"); + expect(result.structuredContent.data).toEqual(data); + }); +}); + +describe("machine-readable response contracts", () => { + it("keeps large resource JSON valid and within budget, including escaped text", () => { + const value = { data: '\n"\\'.repeat(20000) }; + for (const budget of [32, 100, 50000]) { + const result = safeStringify(value, budget); + expect(result.length).toBeLessThanOrEqual(budget); + expect(JSON.parse(result).truncated).toBe(true); + } + expect(safeStringify(undefined)).toBe("null"); + expect(toolJson(value).structuredContent?.data).toMatchObject({ truncated: true }); + }); + + it("recognizes typed errors without marking normal debugging prose as failure", () => { + expect(analyzeMessageResponse({ parts: [{ type: "text", text: "Fixed the error in validation." }] }).hasError).toBe(false); + expect(analyzeMessageResponse({ info: { error: { name: "APIError", data: { message: "Unavailable" } } }, parts: [] }).hasError).toBe(true); + expect(analyzeMessageResponse({ parts: [{ type: "tool", state: { status: "error", error: "Compiler failed" } }] }).hasError).toBe(true); + expect(toolResult("summary", false, { data: [1] }).structuredContent).toEqual({ data: [1] }); + const structured = { info: { structured: { answer: 42 } }, parts: [] }; + expect(analyzeMessageResponse(structured)).toEqual({ isEmpty: false, hasError: false, warning: null }); + expect(JSON.parse(formatMessageResponse(structured))).toEqual({ answer: 42 }); + }); + + it("returns navigable SDK symbol locations and preserves structured source data", async () => { + const { client, call } = capture(); + const data = [{ name: "example", kind: 12, location: { uri: "file:///project/example.ts", range: { start: { line: 0, character: 0 } } } }]; + client.get.mockResolvedValue(data); + const result = await call("opencode_find_symbol", { query: "example" }); + expect(result.content[0].text).toContain("file:///project/example.ts:1"); + expect(result.content[0].text).not.toContain("[object Object]"); + expect(result.structuredContent.data).toEqual(data); + }); + + it("returns session creation IDs as structured data", async () => { + const { client, call } = capture(); + const session = { id: "ses_example", title: "Example" }; + client.post.mockResolvedValue(session); + expect((await call("opencode_session_create", {})).structuredContent).toEqual({ sessionId: session.id, session }); + }); + + it("forwards supported prompt format for sync and async messages", async () => { + const { client, call, tools } = capture(); + const format = { type: "json_schema", schema: { type: "object" }, retryCount: 1 }; + client.post.mockResolvedValue({ info: { id: "msg_example" }, parts: [{ type: "text", text: "{}" }] }); + await call("opencode_message_send", { sessionId: "ses_example", text: "Test", format }); + expect(client.post.mock.lastCall?.[1]).toMatchObject({ format }); + const asynchronous = await call("opencode_message_send_async", { sessionId: "ses_example", text: "Test", format }); + expect(client.post.mock.lastCall?.[1]).toMatchObject({ format }); + expect(asynchronous.structuredContent.data.messageId).toMatch(/^msg_[a-f0-9]{26}$/); + expect(client.post.mock.lastCall?.[1].messageID).toBe(asynchronous.structuredContent.data.messageId); + expect(tools.get("opencode_shell_execute")!.schema).not.toHaveProperty("format"); + }); +}); + +describe("project-scoped resource contracts", () => { + it.each(["/srv/project with spaces", "C:\\work\\project", "/srv/100%ready"])("reads server directory %s without local resolution", async (directory) => { + const { resources, client } = capture(); + client.get.mockResolvedValue({ id: "project" }); + const resource = resources.get("project-scoped")!; + const uri = new URL(`opencode://projects/${encodeURIComponent(directory)}/current`); + const variables = (resource.uri as ResourceTemplate).uriTemplate.match(uri.href)!; + const result = await resource.handler(uri, variables); + expect(client.get).toHaveBeenLastCalledWith("/project/current", undefined, directory); + expect(result.contents[0].uri).toBe(uri.href); + }); + + it("reads a specific scoped session and transcript", async () => { + const { resources, client } = capture(); + client.get.mockResolvedValue([]); + for (const [name, suffix, endpoint] of [["session-scoped", "", ""], ["session-messages-scoped", "/messages", "/message"]]) { + const resource = resources.get(name)!; + const uri = new URL(`opencode://projects/%2Fsrv%2Fproject/sessions/ses_example${suffix}`); + await resource.handler(uri, (resource.uri as ResourceTemplate).uriTemplate.match(uri.href)!); + expect(client.get).toHaveBeenLastCalledWith(`/session/ses_example${endpoint}`, undefined, "/srv/project"); + } + }); +}); diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts index dba8a50..23c3531 100644 --- a/tests/helpers.test.ts +++ b/tests/helpers.test.ts @@ -21,6 +21,18 @@ import { // ─── formatMessageResponse ─────────────────────────────────────────────── describe("formatMessageResponse", () => { + it("shows validated JSON instead of the StructuredOutput receipt", () => { + const message = { + info: { role: "assistant", structured: { status: "ok", sum: 42 } }, + parts: [ + { type: "tool", tool: "StructuredOutput", state: { status: "completed", output: "Structured output captured successfully." } }, + { type: "step-finish", cost: 0, tokens: { output: 23 } }, + ], + }; + expect(JSON.parse(formatMessageResponse(message))).toEqual({ status: "ok", sum: 42 }); + expect(formatMessageList([message])).toContain('"sum": 42'); + expect(formatMessageList([message])).not.toContain("Agent performed"); + }); it("returns empty string for null/undefined input", () => { expect(formatMessageResponse(null)).toBe(""); expect(formatMessageResponse(undefined)).toBe(""); @@ -401,22 +413,22 @@ describe("analyzeMessageResponse", () => { expect(result.hasError).toBe(true); expect(result.isEmpty).toBe(false); expect(result.warning).toContain("Unauthorized"); - expect(result.warning).toContain("authentication"); + expect(result.warning).not.toContain("verify your API key"); }); - it("detects error keywords in text parts", () => { + it("does not treat error keywords in prose as tool failure", () => { const result = analyzeMessageResponse({ parts: [{ type: "text", text: "Error: invalid key provided" }], }); - expect(result.hasError).toBe(true); - expect(result.warning).toContain("invalid key"); + expect(result.hasError).toBe(false); + expect(result.warning).toBeNull(); }); - it("detects 'unauthorized' keyword in text", () => { + it("preserves explanations of unauthorized requests as content", () => { const result = analyzeMessageResponse({ parts: [{ type: "text", text: "Request unauthorized by provider" }], }); - expect(result.hasError).toBe(true); + expect(result.hasError).toBe(false); }); it("returns no warning for valid response with text", () => { @@ -520,9 +532,9 @@ describe("redactSecrets", () => { expect(result.db_password).toBe("hunt***REDACTED***"); }); - it("does not redact short values (<=8 chars)", () => { + it("fully redacts short credentials without exposing a prefix", () => { const result = redactSecrets({ api_key: "short" }) as Record; - expect(result.api_key).toBe("short"); + expect(result.api_key).toBe("***REDACTED***"); }); it("does not redact non-sensitive keys", () => { @@ -622,9 +634,8 @@ describe("safeStringify", () => { it("truncates long strings", () => { const big = { data: "x".repeat(100000) }; const result = safeStringify(big, 100); - expect(result.length).toBeLessThan(200); - expect(result).toContain("truncated"); - expect(result).toContain("more characters"); + expect(result.length).toBeLessThanOrEqual(100); + expect(JSON.parse(result)).toMatchObject({ truncated: true, originalLength: JSON.stringify(big, null, 2).length }); }); it("does not truncate when under limit", () => { @@ -712,7 +723,7 @@ describe("toolError", () => { it("suggests rate limit workaround for 429 errors", () => { const result = toolError(new Error("Rate limit exceeded (429)")); expect(result.content[0].text).toContain("Suggestions"); - expect(result.content[0].text).toContain("minimax-m2.1-free"); + expect(result.content[0].text).toContain("opencode_provider_models"); }); it("suggests server check for connection errors", () => { diff --git a/tests/input.test.ts b/tests/input.test.ts new file mode 100644 index 0000000..0eee4dc --- /dev/null +++ b/tests/input.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { decodeInputResponses, inputAnswerSchema, inputRequests, supportsForm } from "../src/task-input.js"; +import { registerInputTools } from "../src/tools/input.js"; +import { McpServer, withStructuredText } from "../src/mcp-server.js"; +import { OpenCodeClient } from "../src/client.js"; +import { JobService } from "../src/jobs.js"; + +const pending = [ + { id: "perm1", kind: "permission" as const, sessionID: "ses1", permission: "bash", patterns: ["npm test"] }, + { id: "q1", kind: "question" as const, sessionID: "ses1", questions: [ + { question: "Target?", options: [{ label: "web" }, { label: "mobile" }] }, + { question: "Checks?", multiple: true, options: [{ label: "unit" }, { label: "integration" }] }, + ] }, +]; + +function setup() { + const entries = new Map(); + const server = { tool: (name: string, _description: string, shape: z.ZodRawShape, ...rest: unknown[]) => { + entries.set(name, { shape, handler: rest.at(-1) as Function }); + } } as unknown as McpServer; + const client = { get: vi.fn(async () => []), post: vi.fn(async () => true) }; + const job = { jobId: "job_test", sessionId: "ses1", status: "input_required", inputs: pending }; + const jobs = { get: vi.fn(async () => job), update: vi.fn(async () => ({ ...job, status: "running", inputs: [] })), + list: vi.fn(async () => [job]), cancel: vi.fn(async () => ({ ...job, status: "cancelled" })) }; + registerInputTools(server, client as unknown as OpenCodeClient, jobs as unknown as JobService); + const call = (name: string, input: unknown, extra?: unknown) => { + const entry = entries.get(name)!; + return entry.handler(z.object(entry.shape).parse(input), extra); + }; + return { call, client, jobs, job }; +} + +describe("input form translation", () => { + it("requires explicit permission decisions and never preselects approval", () => { + const requests = inputRequests(pending); + const permission: any = requests["permission:perm1"]; + expect(permission.method).toBe("elicitation/create"); + expect(permission.params.requestedSchema.properties.decision.enum).toEqual(["once", "always", "reject"]); + expect(permission.params.requestedSchema.properties.decision).not.toHaveProperty("default"); + expect(permission.params.requestedSchema.required).toEqual(["decision"]); + }); + + it("decodes mixed form answers and maps explicit decline to rejection", () => { + expect(decodeInputResponses(pending, { + "permission:perm1": { action: "decline" }, + "question:q1": { action: "accept", content: { answer_0: "web", answer_1: '["unit","integration"]' } }, + })).toEqual([ + { id: "perm1", kind: "permission", reply: "reject" }, + { id: "q1", kind: "question", answers: [["web"], ["unit", "integration"]] }, + ]); + }); + + it("validates missing decisions and mutually exclusive answer kinds", () => { + expect(() => decodeInputResponses(pending, { "permission:perm1": { action: "accept" } })).toThrow(); + for (const value of [ + { id: "x", kind: "permission" }, + { id: "x", kind: "permission", reply: "once", answers: [] }, + { id: "x", kind: "question", answers: [], reject: true }, + { id: "x", kind: "question" }, + ]) expect(inputAnswerSchema.safeParse(value).success).toBe(false); + }); + + it("ignores unknown response keys and checks form capability per request", () => { + expect(decodeInputResponses(pending, { "question:old": { action: "accept" } })).toEqual([]); + expect(supportsForm({ elicitation: { form: {} } })).toBe(true); + for (const capabilities of [undefined, {}, { elicitation: {} }, { elicitation: { form: null } }]) expect(supportsForm(capabilities)).toBe(false); + }); +}); + +describe("question and job input tools", () => { + it("keeps envelope text and error flags authoritative when job data contains undefined text", () => { + const result = withStructuredText({ content: [{ type: "text", text: "Job is running" }], + structuredContent: { text: undefined, isError: undefined, status: "running" } }); + expect(result.structuredContent).toMatchObject({ text: "Job is running", isError: false, status: "running" }); + }); + + it("filters questions by session and encodes reply request IDs", async () => { + const { call, client } = setup(); + client.get.mockResolvedValue([{ id: "q1", sessionID: "ses1" }, { id: "q2", sessionID: "other" }] as never); + const list = await call("opencode_question_list", { sessionId: "ses1", directory: "/remote/project" }); + expect(list.structuredContent.data).toEqual([{ id: "q1", sessionID: "ses1" }]); + await call("opencode_question_reply", { requestId: "q/1", answers: [["web"]], directory: "/remote/project" }); + expect(client.post).toHaveBeenLastCalledWith("/question/q%2F1/reply", { answers: [["web"]] }, { directory: "/remote/project" }); + }); + + it("returns manual input data when the client cannot display forms", async () => { + const { call, jobs } = setup(); + const result = await call("opencode_job_input", { jobId: "job_test" }); + expect(result.structuredContent).toMatchObject({ status: "input_required", inputs: pending }); + expect(jobs.update).not.toHaveBeenCalled(); + }); + + it("returns an MCP input_required result only for form-capable clients", async () => { + const { call, jobs } = setup(); + const result = await call("opencode_job_input", { jobId: "job_test" }, { mcpReq: { envelope: { "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } } } } }); + expect(result.resultType).toBe("input_required"); + expect(result.inputRequests).toHaveProperty("permission:perm1"); + expect(jobs.update).not.toHaveBeenCalled(); + }); + + it("forwards explicit decisions and validates MRTR responses", async () => { + const { call, jobs } = setup(); + const responses = [{ id: "perm1", kind: "permission", reply: "once" }]; + await call("opencode_job_input", { jobId: "job_test", responses }); + expect(jobs.update).toHaveBeenLastCalledWith("job_test", responses, expect.any(Object)); + await call("opencode_job_input", { jobId: "job_test" }, { mcpReq: { inputResponses: { "permission:perm1": { action: "accept", content: { decision: "reject" } } } } }); + expect(jobs.update).toHaveBeenLastCalledWith("job_test", [{ id: "perm1", kind: "permission", reply: "reject" }], expect.any(Object)); + }); + + it("does not submit an empty update for stale MRTR response keys", async () => { + const { call, jobs } = setup(); + const result = await call("opencode_job_input", { jobId: "job_test" }, { mcpReq: { inputResponses: { "question:old": { action: "accept", content: {} } } } }); + expect(jobs.update).not.toHaveBeenCalled(); + expect(result.isError).toBeUndefined(); + }); +}); diff --git a/tests/jobs.test.ts b/tests/jobs.test.ts new file mode 100644 index 0000000..7996266 --- /dev/null +++ b/tests/jobs.test.ts @@ -0,0 +1,408 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, readFile, readdir, rm, stat, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir, hostname } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { JobService, observeSession } from "../src/jobs.js"; +import { OpenCodeClient, OpenCodeError, OpenCodeSubmissionError } from "../src/client.js"; +import { withRequestOptions } from "../src/async.js"; + +let root: string; +beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "opencode-jobs-")); }); +afterEach(async () => { vi.restoreAllMocks(); await rm(root, { recursive: true, force: true }); }); + +function backend() { + let status = "idle"; + let messages: any[] = []; + let questions: any[] = []; + let permissions: any[] = []; + const get = vi.fn(async (path: string) => { + if (path === "/session/status") return { session: { type: status } }; + if (path === "/session/session") return { id: "session", title: "Fixture", summary: { files: 2 } }; + if (path === "/session/session/message") return messages; + if (path === "/question") return questions; + if (path === "/permission") return permissions; + throw new Error(`Unexpected GET ${path}`); + }); + const post = vi.fn(async (path: string, body?: any) => { + if (path === "/session") return { id: "session" }; + if (path.endsWith("/prompt_async")) { + messages.push({ info: { id: body.messageID, role: "user", time: { created: Date.now() } }, parts: [] }); + return undefined; + } + if (path === "/question/question-1/reply" || path === "/question/question-1/reject") { questions = []; return true; } + if (path === "/permission/permission-1/reply") { permissions = []; return true; } + if (path.endsWith("/abort")) return true; + throw new Error(`Unexpected POST ${path}`); + }); + const client = { getBaseUrl: () => "http://fixture:4096", get, post } as unknown as OpenCodeClient; + return { + client, get, post, + setStatus: (next: string) => { status = next; }, + setMessages: (next: any[]) => { messages = next; }, + setQuestions: (next: any[]) => { questions = next; }, + setPermissions: (next: any[]) => { permissions = next; }, + complete(parentID: string, overrides: Record = {}) { + messages.push({ info: { id: "msg_assistant", parentID, role: "assistant", time: { created: Date.now() + 1, completed: Date.now() + 2 }, ...overrides }, parts: [{ type: "text", text: "Fixed the error successfully." }] }); + }, + }; +} +async function namespace() { return join(root, (await readdir(root))[0]); } + +describe("OpenCode structured-output observation", () => { + const formatError = () => new OpenCodeError("Cannot encode format", 400, "GET", "/session/session/message", + JSON.stringify({ name: "BadRequest", data: { kind: "Body", message: 'Expected OutputFormatJsonSchema, got {"type":"json_schema"}\n at [0]["info"]["format"]' } })); + const result = (parentID = "target", structured: unknown = { sum: 42 }) => ({ + info: { id: "assistant", parentID, role: "assistant", structured, finish: "tool-calls", time: { created: 2, completed: 3 } }, + parts: [{ type: "tool", tool: "StructuredOutput", state: { status: "completed", output: "Structured output captured successfully." } }], + }); + function brokenHistory(latest: unknown[] | Error) { + const fixture = backend(); + const get = fixture.get.getMockImplementation()!; + fixture.get.mockImplementation(async (path: string, ...args: any[]) => { + if (path.endsWith("/message")) { + if (args[0]?.limit !== "1") throw formatError(); + if (latest instanceof Error) throw latest; + return latest; + } + return get(path); + }); + return fixture; + } + + it("recognizes a final StructuredOutput tool call with complete history", async () => { + const fixture = backend(); + fixture.setMessages([{ info: { role: "user", id: "target", time: { created: 1 } } }, result()]); + expect(await observeSession(fixture.client, "session", undefined, "target")).toMatchObject({ status: "completed", result: { sum: 42 } }); + }); + + it("recovers the correlated result through a bounded read without resubmitting", async () => { + const fixture = brokenHistory([result()]); + const observed = await observeSession(fixture.client, "session", "/remote/project", "target"); + expect(observed).toMatchObject({ status: "completed", result: { sum: 42 } }); + expect(JSON.parse(observed.text!)).toEqual({ sum: 42 }); + expect(fixture.get).toHaveBeenCalledWith("/session/session/message", { limit: "1" }, "/remote/project", expect.objectContaining({ signal: expect.any(AbortSignal) })); + expect(fixture.post).not.toHaveBeenCalled(); + }); + + it.each([[], [result("another-turn")], formatError()].map(latest => ({ latest })))("keeps incomplete or unrelated history unknown ($latest)", async ({ latest }) => { + const fixture = brokenHistory(latest); + fixture.setQuestions([{ id: "unrelated", sessionID: "session" }]); + const observed = await observeSession(fixture.client, "session", undefined, "target"); + expect(observed).toMatchObject({ status: "unknown", inputs: [], error: { name: "MessageHistoryUnavailable" } }); + expect(fixture.get).not.toHaveBeenCalledWith("/question", expect.anything(), expect.anything(), expect.anything()); + }); + + it("does not claim completion while OpenCode is still busy", async () => { + const fixture = brokenHistory([result()]); + fixture.setStatus("busy"); + expect((await observeSession(fixture.client, "session", undefined, "target")).status).toBe("unknown"); + }); + + it("does not infer a target from partial history", async () => { + const fixture = brokenHistory([result()]); + await expect(observeSession(fixture.client, "session")).rejects.toThrow("Cannot encode format"); + expect(fixture.get.mock.calls.filter(([path]) => path.endsWith("/message"))).toHaveLength(1); + }); + + it("preserves unrelated errors from either read", async () => { + const denied = new OpenCodeError("Forbidden", 403, "GET", "/session/session/message", "{}"); + const fixture = brokenHistory(denied); + await expect(observeSession(fixture.client, "session", undefined, "target")).rejects.toBe(denied); + fixture.get.mockRejectedValue(denied); + await expect(observeSession(fixture.client, "session", undefined, "target")).rejects.toBe(denied); + }); +}); + +it("persists ownership before dispatch, with safe file modes and no prompt", async () => { + const fixture = backend(); + const original = fixture.post.getMockImplementation()!; + fixture.post.mockImplementation(async (path, body) => { + if (path === "/session") { + const files = await readdir(await namespace()); + expect(files.some((name) => name.endsWith(".json"))).toBe(true); + } + if (path.endsWith("/prompt_async")) { + const files = await readdir(await namespace()); + const saved = JSON.parse(await readFile(join(await namespace(), files.find((name) => name.endsWith(".json"))!), "utf8")); + expect(saved.sessionId).toBe("session"); + expect(saved.messageId).toBe(body.messageID); + expect(saved.status).toBe("unknown"); + expect(JSON.stringify(saved)).not.toContain("private submitted prompt"); + } + return original(path, body); + }); + const service = new JobService(fixture.client, { storeRoot: root, scope: "fixture-identity" }); + const result = await service.start({ prompt: "private submitted prompt", directory: "/remote/project" }); + expect(result.status).toBe("accepted"); + expect(result.messageId).toMatch(/^msg_[a-f0-9]{26}$/); + expect(result.jobId).toMatch(/^job_[a-f0-9]{32}$/); + const path = join(await namespace(), `${result.jobId}.json`); + expect(await readFile(path, "utf8")).not.toContain("private submitted prompt"); + if (process.platform !== "win32") { + expect((await stat(path)).mode & 0o777).toBe(0o600); + expect((await stat(await namespace())).mode & 0o777).toBe(0o700); + } +}); + +it("reconnects a new service instance to a persisted job and retains terminal output", async () => { + const fixture = backend(); + const first = new JobService(fixture.client, { storeRoot: root, scope: "user-one" }); + const created = await first.start({ prompt: "fixture", format: { type: "json_schema", schema: { type: "object" } } }); + fixture.complete(created.messageId!, { structured: { files: 2 } }); + const second = new JobService(fixture.client, { storeRoot: root, scope: "user-one" }); + const complete = await second.get(created.jobId!); + expect(complete.status).toBe("completed"); + expect(complete.result).toEqual({ files: 2 }); + fixture.get.mockRejectedValue(new Error("server offline")); + expect((await first.get(created.jobId!)).status).toBe("completed"); + expect(await second.list()).toHaveLength(1); + await expect(new JobService(fixture.client, { storeRoot: root, scope: "user-two" }).get(created.jobId!)).rejects.toThrow("scope"); +}); + +it("retains all known IDs when accepted submission loses its response", async () => { + const fixture = backend(); + fixture.post.mockImplementation(async (path) => { + if (path === "/session") return { id: "session" }; + throw new OpenCodeSubmissionError("POST", path, new Error("socket hang up")); + }); + const service = new JobService(fixture.client, { storeRoot: root }); + const job = await service.start({ prompt: "fixture" }); + expect(job).toMatchObject({ status: "unknown", sessionId: "session", jobId: expect.any(String), messageId: expect.any(String) }); + expect(fixture.post.mock.calls.filter(([path]) => path.endsWith("/prompt_async"))).toHaveLength(1); + expect((await service.get(job.jobId!)).status).toBe("unknown"); + fixture.complete(job.messageId!); + expect((await service.get(job.jobId!)).status).toBe("completed"); +}); + +it("retains a job handle even if session creation outcome is unknown", async () => { + const fixture = backend(); + fixture.post.mockRejectedValue(new OpenCodeSubmissionError("POST", "/session", new Error("socket hang up"))); + const service = new JobService(fixture.client, { storeRoot: null }); + const job = await service.start({ prompt: "fixture" }); + expect(job.status).toBe("unknown"); + expect(job.sessionId).toBeUndefined(); + expect((await service.get(job.jobId!)).jobId).toBe(job.jobId); + await expect(service.cancel(job.jobId!)).rejects.toThrow("session creation outcome"); + expect(fixture.post).toHaveBeenCalledTimes(1); +}); + +it("marks explicit submission rejection failed without resubmitting", async () => { + const fixture = backend(); + fixture.post.mockRejectedValue(new OpenCodeError("forbidden", 403, "POST", "/session", "fixture")); + const job = await new JobService(fixture.client, { storeRoot: null }).start({ prompt: "fixture", sessionId: "session" }); + expect(job.status).toBe("failed"); + expect(job.error).toMatchObject({ status: 403 }); + expect(fixture.post).toHaveBeenCalledTimes(1); +}); + +describe("turn-specific observations", () => { + it("does not mistake idle/no assistant, latest user, or old assistant for completion", async () => { + const fixture = backend(); + fixture.complete("old-parent"); + fixture.setMessages([ + { info: { id: "old-assistant", role: "assistant", parentID: "old-parent", time: { created: 1, completed: 2 } }, parts: [] }, + { info: { id: "target-user", role: "user", time: { created: 3 } }, parts: [] }, + ]); + expect((await observeSession(fixture.client, "session", undefined, "target-user")).status).toBe("accepted"); + expect((await observeSession(fixture.client, "session")).status).toBe("accepted"); + fixture.setMessages([]); + expect((await observeSession(fixture.client, "session")).status).toBe("accepted"); + }); + + it.each(["ProviderAuthError", "StructuredOutputError", "MessageAbortedError"])("classifies structured %s while idle", async (name) => { + const fixture = backend(); + fixture.complete("user", { error: { name, data: { message: "fixture typed failure" } } }); + const result = await observeSession(fixture.client, "session", "/remote/project", "user"); + expect(result.status).toBe(name === "MessageAbortedError" ? "cancelled" : "failed"); + expect(result.error).toMatchObject({ name }); + }); + + it("requires completion metadata and ignores successful prose containing error", async () => { + const fixture = backend(); + fixture.complete("user", { time: { created: 1 } }); + expect((await observeSession(fixture.client, "session", undefined, "user")).status).toBe("accepted"); + fixture.setMessages([]); + fixture.complete("user"); + const result = await observeSession(fixture.client, "session", undefined, "user"); + expect(result.status).toBe("completed"); + expect(result.text).toContain("Fixed the error"); + expect(fixture.get.mock.calls.some(([path]) => path.includes("/diff"))).toBe(false); + }); + + it("does not finish a tool-call intermediate step", async () => { + const fixture = backend(); + fixture.complete("user", { finish: "tool-calls" }); + expect((await observeSession(fixture.client, "session", undefined, "user")).status).toBe("accepted"); + }); + + it("exposes pending input only for the requested session", async () => { + const fixture = backend(); + fixture.setStatus("busy"); + fixture.setQuestions([{ id: "question-1", sessionID: "session", questions: [{ question: "Select a color", options: [] }] }, { id: "other", sessionID: "another", questions: [] }]); + fixture.setPermissions([{ id: "permission-1", sessionID: "session", permission: "edit", patterns: ["src/**"] }]); + const result = await observeSession(fixture.client, "session"); + expect(result.status).toBe("input_required"); + expect(result.inputs?.map((input) => [input.kind, input.id])).toEqual([["question", "question-1"], ["permission", "permission-1"]]); + }); + + it("accepts unsupported pending endpoints only for HTTP 404", async () => { + const fixture = backend(); + const original = fixture.get.getMockImplementation()!; + fixture.get.mockImplementation(async (path) => { + if (path === "/question" || path === "/permission") throw new OpenCodeError("unsupported", 404, "GET", path, ""); + return original(path); + }); + expect((await observeSession(fixture.client, "session")).status).toBe("accepted"); + fixture.get.mockImplementation(async (path) => { + if (path === "/question") throw new OpenCodeError("unauthorized", 401, "GET", path, ""); + return original(path); + }); + await expect(observeSession(fixture.client, "session")).rejects.toMatchObject({ status: 401 }); + }); +}); + +it("deduplicates permission replies across two service instances", async () => { + const fixture = backend(); + const first = new JobService(fixture.client, { storeRoot: root }); + const second = new JobService(fixture.client, { storeRoot: root }); + const job = await first.start({ prompt: "fixture" }); + fixture.setPermissions([{ id: "permission-1", sessionID: "session", permission: "edit", patterns: ["src/**"] }]); + const responses = [{ id: "permission-1", kind: "permission" as const, reply: "once" as const }]; + await Promise.all([first.update(job.jobId!, responses), second.update(job.jobId!, responses)]); + expect(fixture.post.mock.calls.filter(([path]) => path === "/permission/permission-1/reply")).toHaveLength(1); + await expect(first.update(job.jobId!, [{ ...responses[0], reply: "always" }])).rejects.toThrow("different submitted response"); +}); + +it("does not replay an ambiguous question reply after reconnection", async () => { + const fixture = backend(); + const first = new JobService(fixture.client, { storeRoot: root }); + const job = await first.start({ prompt: "fixture" }); + fixture.setQuestions([{ id: "question-1", sessionID: "session", questions: [] }]); + fixture.post.mockRejectedValue(new OpenCodeSubmissionError("POST", "/question/question-1/reply", new Error("socket hang up"))); + const responses = [{ id: "question-1", kind: "question" as const, answers: [["Blue"]] }]; + await expect(first.update(job.jobId!, responses)).rejects.toBeInstanceOf(OpenCodeSubmissionError); + const second = new JobService(fixture.client, { storeRoot: root }); + await expect(second.update(job.jobId!, responses)).rejects.toThrow("cannot be safely resent"); + expect(fixture.post.mock.calls.filter(([path]) => path === "/question/question-1/reply")).toHaveLength(1); +}); + +it("does not reply to another session's pending question or auto-approve invalid input", async () => { + const fixture = backend(); + const service = new JobService(fixture.client, { storeRoot: null }); + const job = await service.start({ prompt: "fixture" }); + fixture.setQuestions([{ id: "question-1", sessionID: "another-session", questions: [] }]); + await expect(service.update(job.jobId!, [{ id: "question-1", kind: "question", answers: [["Yes"]] }])).rejects.toThrow("does not belong"); + expect(fixture.post).toHaveBeenCalledTimes(2); +}); + +it("explicitly aborts owned work once and never aborts on observer cancellation", async () => { + const fixture = backend(); + const service = new JobService(fixture.client, { storeRoot: root }); + const job = await service.start({ prompt: "fixture" }); + const controller = new AbortController(); + controller.abort(); + await expect(service.get(job.jobId!, { signal: controller.signal })).rejects.toMatchObject({ name: "AbortError" }); + expect(fixture.post.mock.calls.filter(([path]) => path.endsWith("/abort"))).toHaveLength(0); + expect((await service.cancel(job.jobId!)).status).toBe("cancelled"); + expect((await service.cancel(job.jobId!)).status).toBe("cancelled"); + expect(fixture.post.mock.calls.filter(([path]) => path.endsWith("/abort"))).toHaveLength(1); +}); + +it("refuses session-wide cancellation when a newer turn is active", async () => { + const fixture = backend(); + const service = new JobService(fixture.client, { storeRoot: null }); + const job = await service.start({ prompt: "fixture" }); + fixture.setMessages([{ info: { id: "newer-user", role: "user", time: { created: Date.now() + 10 } }, parts: [] }]); + await expect(service.cancel(job.jobId!)).rejects.toThrow("newer turn"); + expect(fixture.post.mock.calls.filter(([path]) => path.endsWith("/abort"))).toHaveLength(0); +}); + +it("expires scoped handles without discarding remote sessions", async () => { + const fixture = backend(); + const service = new JobService(fixture.client, { storeRoot: root, ttlMs: 30 }); + const job = await service.start({ prompt: "fixture" }); + await new Promise((resolve) => setTimeout(resolve, 40)); + await expect(service.get(job.jobId!)).rejects.toThrow("expired"); + expect(await service.list()).toEqual([]); + expect(fixture.post.mock.calls.some(([path]) => path.endsWith("/abort"))).toBe(false); +}); + +it("recovers a cross-process lock only after confirming its owner exited", async () => { + const fixture = backend(); + const service = new JobService(fixture.client, { storeRoot: root }); + const job = await service.start({ prompt: "fixture" }); + const exited = spawnSync(process.execPath, ["-e", ""], { encoding: "utf8" }); + expect(exited.status).toBe(0); + const lock = join(await namespace(), `${job.jobId}.lock`); + await mkdir(lock); + await writeFile(join(lock, "owner.json"), JSON.stringify({ pid: exited.pid, host: hostname() })); + expect((await service.get(job.jobId!)).jobId).toBe(job.jobId); + await expect(stat(lock)).rejects.toMatchObject({ code: "ENOENT" }); +}); + +it("fails closed for a live owner's lock and respects cancellation while waiting", async () => { + const fixture = backend(); + const service = new JobService(fixture.client, { storeRoot: root }); + const job = await service.start({ prompt: "fixture" }); + const lock = join(await namespace(), `${job.jobId}.lock`); + await mkdir(lock); + await writeFile(join(lock, "owner.json"), JSON.stringify({ pid: process.pid, host: hostname() })); + await expect(service.get(job.jobId!, { timeout: 30 })).rejects.toMatchObject({ name: "TimeoutError" }); + expect((await stat(lock)).isDirectory()).toBe(true); +}); + +it("inherits cancellation through AsyncLocalStorage for existing tool handlers", async () => { + const controller = new AbortController(); + controller.abort(); + const client = new OpenCodeClient({ baseUrl: "http://127.0.0.1:1" }); + await expect(withRequestOptions({ signal: controller.signal }, () => client.get("/session"))).rejects.toMatchObject({ name: "AbortError" }); +}); + +it("does not surface a newer turn's approval as input for an older job", async () => { + const fixture = backend(); + fixture.setStatus("busy"); + fixture.setMessages([{ info: { id: "new-user", role: "user", time: { created: 10 } }, parts: [] }]); + fixture.setPermissions([{ id: "permission-1", sessionID: "session", permission: "edit", patterns: ["*"] }]); + const result = await observeSession(fixture.client, "session", undefined, "old-user"); + expect(result.status).toBe("unknown"); + expect(result.inputs).toEqual([]); +}); + +it("recognizes completed older turns even while a newer turn is busy", async () => { + const fixture = backend(); + fixture.setStatus("busy"); + fixture.setMessages([ + { info: { id: "old-user", role: "user", time: { created: 1 } }, parts: [] }, + { info: { id: "old-answer", role: "assistant", parentID: "old-user", time: { created: 2, completed: 3 } }, parts: [{ type: "text", text: "Done" }] }, + { info: { id: "new-user", role: "user", time: { created: 4 } }, parts: [] }, + ]); + expect((await observeSession(fixture.client, "session", undefined, "old-user")).status).toBe("completed"); +}); + +it("keeps an ambiguous cancellation unknown without sending abort twice", async () => { + const fixture = backend(); + const service = new JobService(fixture.client, { storeRoot: root }); + const job = await service.start({ prompt: "fixture" }); + fixture.setStatus("busy"); + fixture.post.mockRejectedValue(new OpenCodeSubmissionError("POST", "/session/session/abort", new Error("socket hang up"))); + expect((await service.cancel(job.jobId!)).status).toBe("unknown"); + expect((await service.get(job.jobId!)).status).toBe("unknown"); + expect((await service.cancel(job.jobId!)).status).toBe("unknown"); + expect(fixture.post.mock.calls.filter(([path]) => path.endsWith("/abort"))).toHaveLength(1); +}); + +it("fails closed for interrupted lock initialization or recovery", async () => { + const fixture = backend(); + const service = new JobService(fixture.client, { storeRoot: root }); + const job = await service.start({ prompt: "fixture" }); + const lock = join(await namespace(), `${job.jobId}.lock`); + await mkdir(lock); + await expect(service.get(job.jobId!, { timeout: 30 })).rejects.toMatchObject({ name: "TimeoutError" }); + expect((await stat(lock)).isDirectory()).toBe(true); + const exited = spawnSync(process.execPath, ["-e", ""], { encoding: "utf8" }); + await writeFile(join(lock, "owner.json"), JSON.stringify({ pid: exited.pid, host: hostname() })); + await writeFile(join(lock, "recovery"), ""); + await expect(service.get(job.jobId!, { timeout: 30 })).rejects.toMatchObject({ name: "TimeoutError" }); + expect((await stat(lock)).isDirectory()).toBe(true); +}); diff --git a/tests/modern-stdio.test.ts b/tests/modern-stdio.test.ts new file mode 100644 index 0000000..8a7c76c --- /dev/null +++ b/tests/modern-stdio.test.ts @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createServer, type Server } from "node:http"; +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Client as LegacyClient } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport as LegacyTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +const executable = fileURLToPath(new URL("../dist/index.js", import.meta.url)); +const metadata = { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": { + extensions: { "io.modelcontextprotocol/tasks": {} }, elicitation: { form: {} }, + }, +}; +interface Session { id: string; messageID?: string; prompt?: string; state: "busy" | "idle"; complete?: boolean; } +let http: Server; +let root: string; +let env: Record; +let sessions: Map; +let aborts: number; +let replies: number; +const children: ChildProcessWithoutNullStreams[] = []; +const clients: LegacyClient[] = []; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "opencode-modern-stdio-")); + sessions = new Map(); aborts = 0; replies = 0; + http = createServer(async (req, res) => { + let text = ""; + for await (const chunk of req) text += chunk; + const body = text ? JSON.parse(text) : {}; + const path = new URL(req.url!, "http://fixture").pathname; + const json = (value: unknown) => { res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(value)); }; + if (path === "/global/health") { json({ healthy: true, version: "fixture" }); return; } + if (path === "/session" && req.method === "POST") { + const session: Session = { id: `session_${sessions.size + 1}`, state: "idle" }; + sessions.set(session.id, session); json({ id: session.id }); return; + } + if (path === "/session/status") { json(Object.fromEntries([...sessions.values()].map((session) => [session.id, { type: session.state }]))); return; } + if (path === "/question") { json([]); return; } + if (path === "/permission") { + json([...sessions.values()].filter((session) => session.prompt === "ask permission" && !session.complete).map((session) => ({ + id: `permission_${session.id}`, sessionID: session.id, permission: "edit", patterns: ["src/fixture.ts"], always: [], metadata: {}, + }))); return; + } + if (path.startsWith("/permission/") && path.endsWith("/reply")) { + const session = sessions.get(path.split("/")[2].replace("permission_", ""))!; + replies++; session.complete = true; session.state = "idle"; + json(true); return; + } + const session = sessions.get(path.split("/")[2]); + if (!session) { res.writeHead(404, { "content-type": "application/json" }).end('{"error":"unknown fixture route"}'); return; } + if (path.endsWith("/prompt_async")) { + session.messageID = body.messageID; session.prompt = body.parts[0].text; + session.complete = session.prompt !== "hold" && session.prompt !== "ask permission"; + session.state = session.complete ? "idle" : "busy"; + res.writeHead(204).end(); return; + } + if (path.endsWith("/abort")) { aborts++; session.state = "idle"; json(true); return; } + if (path.endsWith("/message")) { + const messages = session.messageID ? [{ info: { id: session.messageID, role: "user", time: { created: 1 } }, parts: [] }] : []; + if (session.complete) messages.push({ info: { + id: `assistant_${session.id}`, role: "assistant", parentID: session.messageID, + time: { created: 2, completed: 3 }, finish: "stop", structured: { fixture: true }, + }, parts: [{ type: "text", text: "Fixture task completed." }] } as any); + json(messages); return; + } + if (path.endsWith("/todo")) { json([]); return; } + json({ id: session.id, title: "Fixture session", summary: { files: 0 } }); + }); + await new Promise((resolve) => http.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${(http.address() as { port: number }).port}`; + env = Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)); + Object.assign(env, { OPENCODE_BASE_URL: base, OPENCODE_AUTO_SERVE: "false", OPENCODE_TASK_STORE: root, + OPENCODE_SERVER_USERNAME: "", OPENCODE_SERVER_PASSWORD: "", OPENCODE_TOOL_PROFILE: "full", + OPENCODE_DEFAULT_PROVIDER: "fixture", OPENCODE_DEFAULT_MODEL: "fixture" }); +}); + +afterEach(async () => { + for (const client of clients.splice(0)) await client.close(); + for (const child of children.splice(0)) { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, "exit"); child.stdin.end(); await exited; + } + } + http.closeAllConnections(); + await new Promise((resolve) => http.close(() => resolve())); + await rm(root, { recursive: true, force: true }); +}); + +function rawConnection() { + const child = spawn(process.execPath, [executable], { env, stdio: ["pipe", "pipe", "pipe"] }); + children.push(child); + let buffer = ""; let nextId = 0; let stderr = ""; + const pending = new Map void; reject: (error: Error) => void; timer: ReturnType }>(); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.stdout.on("data", (chunk) => { + buffer += chunk; + let newline: number; + while ((newline = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); + if (!line.trim()) continue; + const message = JSON.parse(line); + const handler = pending.get(message.id); + if (handler) { clearTimeout(handler.timer); pending.delete(message.id); handler.resolve(message); } + } + }); + child.on("exit", () => { + for (const handler of pending.values()) { clearTimeout(handler.timer); handler.reject(new Error(`Child exited: ${stderr.slice(-2000)}`)); } + pending.clear(); + }); + return { + async request(method: string, params: Record = {}, meta: Record = metadata) { + const id = ++nextId; + const response = new Promise((resolve, reject) => { + const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timed out waiting for ${method}: ${stderr.slice(-2000)}`)); }, 5000); + pending.set(id, { resolve, reject, timer }); + }); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: { ...params, _meta: meta } }) + "\n"); + return response; + }, + async close() { if (child.exitCode === null) { const exited = once(child, "exit"); child.stdin.end(); await exited; } }, + }; +} + +describe("July 2026 stdio interoperability", () => { + it("serves stateless discovery and a durable task result with a reconnect", async () => { + const first = rawConnection(); + const list = await first.request("tools/list"); + expect(list.error).toBeUndefined(); + const run = list.result.tools.find((tool: any) => tool.name === "opencode_run"); + expect(run).toBeDefined(); + expect(run.outputSchema).toBeDefined(); + expect(list.result.tools.every((tool: any) => tool.annotations)).toBe(true); + const started = await first.request("tools/call", { name: "opencode_run", arguments: { prompt: "complete fixture" } }); + expect(started.error).toBeUndefined(); + expect(started.result).toMatchObject({ resultType: "task", status: "working", taskId: expect.any(String) }); + await first.close(); + const second = rawConnection(); + const get = await second.request("tasks/get", { taskId: started.result.taskId }); + expect(get.error).toBeUndefined(); + expect(get.result).toMatchObject({ resultType: "complete", status: "completed", result: { resultType: "complete", structuredContent: { result: { fixture: true } } } }); + expect(sessions.size).toBe(1); + }); + + it("reports input_required, accepts explicit permission input, and completes", async () => { + const connection = rawConnection(); + const started = await connection.request("tools/call", { name: "opencode_run", arguments: { prompt: "ask permission" } }); + expect(started.error).toBeUndefined(); + const taskId = started.result.taskId; + const get = await connection.request("tasks/get", { taskId }); + expect(get.result.status).toBe("input_required"); + const key = Object.keys(get.result.inputRequests)[0]; + expect(key).toBe("permission:permission_session_1"); + expect(replies).toBe(0); + const updated = await connection.request("tasks/update", { taskId, inputResponses: { + [key]: { action: "accept", content: { decision: "once" } }, + } }); + expect(updated.error).toBeUndefined(); + expect(updated.result.resultType).toBe("complete"); + expect(replies).toBe(1); + expect((await connection.request("tasks/get", { taskId })).result.status).toBe("completed"); + }); + + it("round-trips explicit input forms through the SDK core tool handler", async () => { + const connection = rawConnection(); + const started = await connection.request("tools/call", { name: "opencode_fire", arguments: { prompt: "ask permission" } }); + expect(started.error).toBeUndefined(); + const jobId = started.result.structuredContent.jobId; + const form = await connection.request("tools/call", { name: "opencode_job_input", arguments: { jobId } }); + expect(form.error).toBeUndefined(); + expect(form.result.resultType).toBe("input_required"); + const key = Object.keys(form.result.inputRequests)[0]; + expect(replies).toBe(0); + const finished = await connection.request("tools/call", { name: "opencode_job_input", arguments: { jobId }, + inputResponses: { [key]: { action: "accept", content: { decision: "once" } } } }); + expect(finished.error).toBeUndefined(); + expect(finished.result.structuredContent).toMatchObject({ status: "completed", isError: false }); + expect(replies).toBe(1); + }); + + it("deduplicates the same permission response across two bridge processes", async () => { + const first = rawConnection(); + const second = rawConnection(); + const started = await first.request("tools/call", { name: "opencode_run", arguments: { prompt: "ask permission" } }); + const taskId = started.result.taskId; + const get = await first.request("tasks/get", { taskId }); + const key = Object.keys(get.result.inputRequests)[0]; + const params = { taskId, inputResponses: { [key]: { action: "accept", content: { decision: "once" } } } }; + const results = await Promise.all([first.request("tasks/update", params), second.request("tasks/update", params)]); + for (const result of results) expect(result.error).toBeUndefined(); + expect(replies).toBe(1); + }); + + it("cancels remote work only through an explicit Tasks cancel", async () => { + const connection = rawConnection(); + const started = await connection.request("tools/call", { name: "opencode_run", arguments: { prompt: "hold" } }); + expect(started.error).toBeUndefined(); + const taskId = started.result.taskId; + expect((await connection.request("tasks/get", { taskId })).result.status).toBe("working"); + expect(aborts).toBe(0); + expect((await connection.request("tasks/cancel", { taskId })).error).toBeUndefined(); + expect((await connection.request("tasks/get", { taskId })).result.status).toBe("cancelled"); + expect(aborts).toBe(1); + }); + + it("requires explicit Tasks capability and rejects invalid task arguments", async () => { + const connection = rawConnection(); + const noTasks = { ...metadata, "io.modelcontextprotocol/clientCapabilities": {} }; + const unsupported = await connection.request("tasks/get", { taskId: "fixture" }, noTasks); + expect(unsupported.error?.code).toBe(-32021); + const invalid = await connection.request("tools/call", { name: "opencode_run", arguments: { prompt: "" } }); + expect(invalid.error?.code).toBe(-32602); + expect(sessions.size).toBe(0); + }); + + it("retains legacy SDK v1 handshake, discovery, fire and structured get", async () => { + const legacy = new LegacyClient({ name: "fixture-v1", version: "1.0.0" }); + clients.push(legacy); + await legacy.connect(new LegacyTransport({ command: process.execPath, args: [executable], env, stderr: "pipe" })); + const catalog = await legacy.listTools(); + expect(catalog.tools.some((tool) => tool.name === "opencode_fire")).toBe(true); + const started = await legacy.callTool({ name: "opencode_fire", arguments: { prompt: "complete fixture" } }); + expect(started.isError).not.toBe(true); + const jobId = (started.structuredContent as any).jobId; + expect(jobId).toMatch(/^job_/); + const finished = await legacy.callTool({ name: "opencode_job_get", arguments: { jobId } }); + expect(finished.isError).not.toBe(true); + expect(finished.structuredContent).toMatchObject({ status: "completed", result: { fixture: true } }); + }); +}); diff --git a/tests/resources.test.ts b/tests/resources.test.ts index 9b9775a..4e0d6d4 100644 --- a/tests/resources.test.ts +++ b/tests/resources.test.ts @@ -23,7 +23,7 @@ function captureResources() { const mockServer = { resource: vi.fn( (name: string, uri: string, _opts: unknown, handler: Function) => { - resources.set(name, { uri, handler }); + if (typeof uri === "string") resources.set(name, { uri, handler }); }, ), } as unknown as McpServer; diff --git a/tests/smoke-runner.test.ts b/tests/smoke-runner.test.ts new file mode 100644 index 0000000..553373f --- /dev/null +++ b/tests/smoke-runner.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from "vitest"; +import { FIXTURE_MARKER, runSmoke, smokeEnvironment, smokeOptions } from "../scripts/mcp-smoke-test.mjs"; + +const result = (text = "fixture response", structuredContent?: Record) => ({ + content: [{ type: "text", text }], ...(structuredContent ? { structuredContent } : {}), +}); +const directory = "/disposable/smoke-fixture"; +function fakeClient(overrides: Record = {}) { + const replies: Record = { + opencode_health: result("Status: healthy\nVersion: fixture"), + opencode_file_list: result("README.md"), + opencode_file_read: result(FIXTURE_MARKER), + opencode_find_file: result("README.md"), + opencode_find_text: result(`README.md:1 ${FIXTURE_MARKER}`), + opencode_session_create: result("created", { sessionId: "ses_owned" }), + opencode_wait: result("Status: completed", { status: "completed" }), + opencode_run: result("Status: completed", { status: "completed" }), + ...overrides, + }; + return { + listTools: vi.fn().mockResolvedValue({ tools: [ + "opencode_health", "opencode_session_create", "opencode_session_delete", "opencode_session_share", + "opencode_auth_set", "opencode_fire", "opencode_run", "opencode_wait", "opencode_check", "opencode_project_init", + ].map((name) => ({ name })) }), + callTool: vi.fn(async ({ name }: { name: string }) => { + if (replies[name] instanceof Error) throw replies[name]; + return replies[name] ?? result(); + }), + close: vi.fn().mockResolvedValue(undefined), + }; +} +const names = (client: ReturnType) => client.callTool.mock.calls.map(([call]) => call.name); + +describe("isolated smoke runner", () => { + it("does no inference by default and marks untested capabilities SKIP", async () => { + const client = fakeClient(); + const report = await runSmoke({ client, directory }); + expect(report.exitCode).toBe(0); + for (const name of ["opencode_fire", "opencode_run", "opencode_wait", "opencode_auth_set", "opencode_session_share"]) { + expect(names(client)).not.toContain(name); + expect(report.results).toContainEqual(expect.objectContaining({ name, status: "SKIP" })); + } + expect(names(client)).toContain("opencode_project_init"); + expect(client.callTool).toHaveBeenCalledWith({ name: "opencode_session_delete", arguments: { id: "ses_owned", directory } }, undefined, expect.any(Object)); + expect(client.close).toHaveBeenCalledOnce(); + }); + + it.each([ + { ...result("ID: ses_existing"), isError: true }, + result("Existing sessions: [ses_existing]"), + result("ID: ses_owned\nID: ses_existing"), + result("ID: ses_existing", { sessionId: "invalid/path" }), + ])("never selects or mutates another session when creation fails", async (createReply) => { + const client = fakeClient({ opencode_session_create: createReply }); + const report = await runSmoke({ client, directory }); + expect(report.exitCode).toBe(1); + for (const name of ["opencode_session_list", "opencode_session_delete", "opencode_session_abort", "opencode_session_update", "opencode_session_share", "opencode_fire"]) { + expect(names(client)).not.toContain(name); + } + }); + + it("accepts an anchored legacy ID only from a successful creation response", async () => { + const client = fakeClient({ opencode_session_create: result("Session created.\n\nID: ses_legacy\nTitle: fixture") }); + expect((await runSmoke({ client, directory })).exitCode).toBe(0); + expect(client.callTool).toHaveBeenCalledWith({ name: "opencode_session_delete", arguments: { id: "ses_legacy", directory } }, undefined, expect.any(Object)); + }); + + it("treats backend isError as failure and still cleans up its owned session", async () => { + const client = fakeClient({ opencode_session_get: { ...result("synthetic backend failure"), isError: true } }); + const report = await runSmoke({ client, directory }); + expect(report.exitCode).toBe(1); + expect(report.results).toContainEqual(expect.objectContaining({ name: "opencode_session_get", status: "FAIL" })); + expect(names(client)).toContain("opencode_session_delete"); + expect(JSON.stringify(report)).not.toContain("synthetic backend failure"); + }); + + it("fails fixture assertions even when the server returns successful empty results", async () => { + const client = fakeClient({ opencode_find_file: result("No files found") }); + const report = await runSmoke({ client, directory }); + expect(report.exitCode).toBe(1); + expect(report.results).toContainEqual(expect.objectContaining({ name: "opencode_find_file", status: "FAIL" })); + }); + + it("dispatches explicitly selected inference serially and aborts owned work before deletion", async () => { + const client = fakeClient(); + const report = await runSmoke({ client, directory, inference: true, providerID: "fixture-provider", modelID: "fixture-model" }); + expect(report.exitCode).toBe(0); + const calls = names(client); + expect(calls.indexOf("opencode_fire")).toBeLessThan(calls.indexOf("opencode_wait")); + expect(calls.indexOf("opencode_wait")).toBeLessThan(calls.indexOf("opencode_run")); + expect(calls.indexOf("opencode_session_abort")).toBeLessThan(calls.indexOf("opencode_session_delete")); + for (const name of ["opencode_fire", "opencode_run"]) { + expect(client.callTool).toHaveBeenCalledWith(expect.objectContaining({ name, arguments: expect.objectContaining({ sessionId: "ses_owned", providerID: "fixture-provider", modelID: "fixture-model", directory }) }), undefined, expect.any(Object)); + } + }); + + it("does not dispatch a second prompt when waiting times out", async () => { + const client = fakeClient({ opencode_wait: result("Status: still running", { status: "running" }) }); + const report = await runSmoke({ client, directory, inference: true, providerID: "fixture", modelID: "model" }); + expect(report.exitCode).toBe(1); + expect(names(client)).not.toContain("opencode_run"); + expect(names(client)).toContain("opencode_session_abort"); + expect(names(client)).toContain("opencode_session_delete"); + }); + + it("reports cleanup failure and always closes the client", async () => { + const client = fakeClient({ opencode_session_delete: new Error("synthetic failure") }); + expect((await runSmoke({ client, directory })).exitCode).toBe(1); + expect(client.close).toHaveBeenCalledOnce(); + }); + + it("does no fixture operations after unhealthy-server preflight", async () => { + const client = fakeClient({ opencode_health: result("Status: unhealthy") }); + expect((await runSmoke({ client, directory })).exitCode).toBe(1); + expect(names(client)).toEqual(["opencode_health"]); + }); + + it("fails malformed tool pagination and closes the client", async () => { + const client = fakeClient(); + client.listTools.mockResolvedValue({ tools: [], nextCursor: "repeat" }); + expect((await runSmoke({ client, directory })).exitCode).toBe(1); + expect(client.listTools).toHaveBeenCalledTimes(2); + expect(client.close).toHaveBeenCalledOnce(); + }); +}); + +describe("smoke options", () => { + it("requires explicit inference opt-in even when model defaults exist", () => { + expect(smokeOptions([], { OPENCODE_TEST_PROVIDER: "fixture", OPENCODE_TEST_MODEL: "model" }).inference).toBe(false); + expect(() => smokeOptions(["--inference"], {})).toThrow("requires"); + expect(smokeOptions(["--inference", "--provider", "fixture", "--model", "model"], {})).toEqual(expect.objectContaining({ inference: true, providerID: "fixture", modelID: "model" })); + }); + + it("forwards auth but does not inherit automatic startup or unrelated credentials", () => { + const env = smokeEnvironment({ baseUrl: "http://localhost:4096" }, { + OPENCODE_SERVER_USERNAME: "fixture-user", OPENCODE_SERVER_PASSWORD: "fixture-password", OPENCODE_AUTO_SERVE: "true", UNRELATED_SECRET: "not-forwarded", + }); + expect(env).toEqual({ OPENCODE_BASE_URL: "http://localhost:4096", OPENCODE_SERVER_USERNAME: "fixture-user", OPENCODE_SERVER_PASSWORD: "fixture-password", OPENCODE_AUTO_SERVE: "false" }); + }); + + it("rejects embedded credentials, remote fixture ambiguity, and unknown options", () => { + expect(() => smokeOptions(["--base-url", "http://user:fixture@localhost:4096"], {})).toThrow("credentials"); + expect(() => smokeOptions(["--base-url", "https://example.com"], {})).toThrow("disposable"); + expect(() => smokeOptions(["--model"], {})).toThrow("Missing"); + expect(() => smokeOptions(["--unexpected"], {})).toThrow("Unknown"); + }); +}); diff --git a/tests/stdio-integration.test.ts b/tests/stdio-integration.test.ts index d2eddb1..1a1d5a6 100644 --- a/tests/stdio-integration.test.ts +++ b/tests/stdio-integration.test.ts @@ -34,7 +34,7 @@ describe("MCP process lifecycle", () => { await new Promise((resolve, reject) => { child!.stderr!.on("data", (chunk) => { log += chunk; - if (log.includes("started (OpenCode server at")) resolve(); + if (log.includes("opencode-mcp v3.0.0 started (")) resolve(); }); child!.once("error", reject); child!.once("exit", () => reject(new Error(`Premature exit: ${log}`))); diff --git a/tests/task-transport.test.ts b/tests/task-transport.test.ts new file mode 100644 index 0000000..a1348bc --- /dev/null +++ b/tests/task-transport.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Transport } from "@modelcontextprotocol/server"; +import { TaskTransport, taskFromJob, TASKS_EXTENSION } from "../src/task-transport.js"; +import { JobService, type JobSnapshot } from "../src/jobs.js"; + +// Wire assertions follow immutable official ext-tasks/schema/2026-07-28/schema.ts. +const protocolKey = "io.modelcontextprotocol/protocolVersion"; +const capabilitiesKey = "io.modelcontextprotocol/clientCapabilities"; +const capable = { extensions: { [TASKS_EXTENSION]: {} }, elicitation: { form: {} } }; +const meta = (capabilities: unknown = capable, version = "2026-07-28") => ({ [protocolKey]: version, [capabilitiesKey]: capabilities }); +const snapshot = (status: JobSnapshot["status"]): JobSnapshot => ({ + jobId: "job_test", sessionId: "ses_test", messageId: "msg_test", status, + createdAt: 1000, updatedAt: 2000, expiresAt: 61000, +}); + +async function setup() { + const sent: any[] = []; + const inner = { start: vi.fn(async () => {}), close: vi.fn(async () => {}), send: vi.fn(async (message: unknown) => { sent.push(message); }) } as unknown as Transport; + const jobs = { start: vi.fn(async () => snapshot("accepted")), get: vi.fn(async () => snapshot("running")), + update: vi.fn(async () => snapshot("running")), cancel: vi.fn(async () => snapshot("cancelled")) }; + const transport = new TaskTransport(inner, jobs as unknown as JobService); + const forwarded = vi.fn(); + transport.onmessage = forwarded; + transport.onerror = vi.fn(); + await transport.start(); + const deliver = (message: any) => inner.onmessage!(message); + const request = async (id: number, method: string, params: Record) => { + deliver({ jsonrpc: "2.0", id, method, params }); + await vi.waitFor(() => expect(sent.some(message => message.id === id)).toBe(true)); + return sent.find(message => message.id === id); + }; + return { inner, jobs, transport, forwarded, sent, deliver, request }; +} + +describe("modern Tasks wire results", () => { + it("returns a flat task handle for capable run calls and routes no core traffic", async () => { + const { request, jobs, forwarded } = await setup(); + const result = await request(1, "tools/call", { name: "opencode_run", arguments: { prompt: "Task", providerID: "test", modelID: "model" }, _meta: meta() }); + expect(result.result).toMatchObject({ resultType: "task", taskId: "job_test", status: "working", ttlMs: 60000 }); + expect(result.result).not.toHaveProperty("task"); + expect(jobs.start).toHaveBeenCalledWith(expect.objectContaining({ model: { providerID: "test", modelID: "model" } }), expect.objectContaining({ signal: expect.any(AbortSignal) })); + expect(forwarded).not.toHaveBeenCalled(); + }); + + it("embeds the completed tool result including structured data", async () => { + const { request, jobs } = await setup(); + jobs.get.mockResolvedValue({ ...snapshot("completed"), text: "Done", result: { answer: 42 } }); + const result = await request(1, "tasks/get", { taskId: "job_test", _meta: meta() }); + expect(result.result).toMatchObject({ resultType: "complete", taskId: "job_test", status: "completed", result: { + resultType: "complete", content: [{ type: "text", text: "Done" }], structuredContent: { status: "completed", result: { answer: 42 } }, + } }); + expect(result.result).not.toHaveProperty("task"); + }); + + it("distinguishes tool failure from a JSON-RPC failed task", () => { + const result = taskFromJob({ ...snapshot("failed"), error: { name: "APIError" } }, true); + expect(result.status).toBe("completed"); + expect((result.result as any).isError).toBe(true); + }); + + it("requires Tasks capabilities per request without inferring them from earlier calls", async () => { + const { request } = await setup(); + await request(1, "tasks/get", { taskId: "job_test", _meta: meta() }); + const response = await request(2, "tasks/get", { taskId: "job_test", _meta: meta({}) }); + expect(response.error).toMatchObject({ code: -32021, data: { requiredCapabilities: { extensions: { [TASKS_EXTENSION]: {} } } } }); + }); + + it("does not emit form requests unless this request supports elicitation forms", async () => { + const { request, jobs } = await setup(); + jobs.get.mockResolvedValue({ ...snapshot("input_required"), inputs: [{ id: "p1", sessionID: "ses_test", kind: "permission", permission: "bash" }] }); + const response = await request(1, "tasks/get", { taskId: "job_test", _meta: meta({ extensions: { [TASKS_EXTENSION]: {} } }) }); + expect(response.error).toMatchObject({ code: -32021, data: { requiredCapabilities: { elicitation: { form: {} } } } }); + }); + + it("returns input requests and accepts a partial response with an empty acknowledgement", async () => { + const { request, jobs } = await setup(); + jobs.get.mockResolvedValue({ ...snapshot("input_required"), inputs: [{ id: "p1", sessionID: "ses_test", kind: "permission", permission: "bash" }] }); + const current = await request(1, "tasks/get", { taskId: "job_test", _meta: meta() }); + expect(current.result.inputRequests["permission:p1"].method).toBe("elicitation/create"); + const updated = await request(2, "tasks/update", { taskId: "job_test", inputResponses: { "permission:p1": { action: "accept", content: { decision: "once" } } }, _meta: meta() }); + expect(updated.result).toEqual({ resultType: "complete" }); + expect(jobs.update).toHaveBeenCalledWith("job_test", [{ id: "p1", kind: "permission", reply: "once" }], expect.any(Object)); + }); + + it("acknowledges unknown or already consumed input keys without resubmitting", async () => { + const { request, jobs } = await setup(); + const result = await request(1, "tasks/update", { taskId: "job_test", inputResponses: { old: { action: "accept", content: {} } }, _meta: meta() }); + expect(result.result).toEqual({ resultType: "complete" }); + expect(jobs.update).not.toHaveBeenCalled(); + }); + + it("cancels explicitly with an empty acknowledgement", async () => { + const { request, jobs } = await setup(); + const result = await request(1, "tasks/cancel", { taskId: "job_test", _meta: meta() }); + expect(result.result).toEqual({ resultType: "complete" }); + expect(jobs.cancel).toHaveBeenCalledWith("job_test", expect.any(Object)); + }); + + it("acknowledges accepted cancellation without requiring an immediate terminal state", async () => { + const { request, jobs } = await setup(); + jobs.cancel.mockResolvedValue(snapshot("running")); + const result = await request(1, "tasks/cancel", { taskId: "job_test", _meta: meta() }); + expect(result.result).toEqual({ resultType: "complete" }); + }); +}); + +describe("transport routing and cancellation", () => { + it("forwards legacy initialization and rejects a modern era switch", async () => { + const { deliver, forwarded, request } = await setup(); + deliver({ jsonrpc: "2.0", id: 0, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {} } }); + await vi.waitFor(() => expect(forwarded).toHaveBeenCalledTimes(1)); + const response = await request(1, "tasks/get", { taskId: "job_test", _meta: meta() }); + expect(response.error.code).toBe(-32022); + }); + + it("includes required supported/requested versions in unsupported-version errors", async () => { + const { request } = await setup(); + const response = await request(1, "tasks/get", { taskId: "job_test", _meta: meta(capable, "2099-01-01") }); + expect(response.error).toMatchObject({ code: -32022, data: { supported: expect.any(Array), requested: "2099-01-01" } }); + }); + + it("forwards modern core tools when no Tasks capability is advertised", async () => { + const { deliver, forwarded, jobs } = await setup(); + deliver({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "opencode_run", arguments: { prompt: "Task" }, _meta: meta({}) } }); + await vi.waitFor(() => expect(forwarded).toHaveBeenCalledTimes(1)); + expect(jobs.start).not.toHaveBeenCalled(); + }); + + it("allows a discovery probe followed by legacy initialization", async () => { + const { deliver, forwarded } = await setup(); + deliver({ jsonrpc: "2.0", id: 1, method: "server/discover", params: { _meta: meta() } }); + deliver({ jsonrpc: "2.0", id: 2, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {} } }); + await vi.waitFor(() => expect(forwarded).toHaveBeenCalledTimes(2)); + }); + + it("does not pin the era for a malformed opener", async () => { + const { request, deliver, forwarded } = await setup(); + const invalid = await request(1, "tasks/get", { taskId: "job_test", _meta: { [protocolKey]: "2026-07-28", [capabilitiesKey]: "invalid" } }); + expect(invalid.error.code).toBe(-32602); + deliver({ jsonrpc: "2.0", id: 2, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {} } }); + await vi.waitFor(() => expect(forwarded).toHaveBeenCalledTimes(1)); + }); + + it("does not forward legacy core traffic after a native task opener", async () => { + const { request, forwarded } = await setup(); + await request(1, "tasks/get", { taskId: "job_test", _meta: meta() }); + const result = await request(2, "tools/list", {}); + expect(result.error.code).toBe(-32022); + expect(forwarded).not.toHaveBeenCalled(); + }); + + it("rejects removed legacy task operations", async () => { + const { request } = await setup(); + for (const [index, method] of ["tasks/result", "tasks/list"].entries()) { + const response = await request(index + 1, method, { taskId: "job_test", _meta: meta() }); + expect(response.error.code).toBe(-32601); + } + }); + + it("routes notifications/cancelled to active observation only", async () => { + const { deliver, jobs, forwarded } = await setup(); + let signal: AbortSignal | undefined; + jobs.get.mockImplementation((_id: unknown, options: any) => { + signal = options.signal; + return new Promise((_resolve, reject) => options.signal.addEventListener("abort", () => reject(new Error("Cancelled")), { once: true })); + }); + deliver({ jsonrpc: "2.0", id: 1, method: "tasks/get", params: { taskId: "job_test", _meta: meta() } }); + await vi.waitFor(() => expect(signal).toBeDefined()); + deliver({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 1 } }); + expect(signal!.aborted).toBe(true); + expect(jobs.cancel).not.toHaveBeenCalled(); + expect(forwarded).toHaveBeenCalled(); + }); +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 5128665..80b0ba2 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { OpenCodeClient } from "../src/client.js"; +import { OpenCodeClient, OpenCodeError } from "../src/client.js"; import { registerGlobalTools } from "../src/tools/global.js"; import { registerWorkflowTools } from "../src/tools/workflow.js"; import { registerConfigTools } from "../src/tools/config.js"; @@ -1382,7 +1382,7 @@ describe("Tool handlers", () => { registerWorkflowTools(mockServer, mockClient); const handler = tools.get("opencode_provider_test")!; - const result = await handler({ providerId: "anthropic" }); + const result = await handler({ providerId: "anthropic", modelID: "test-model" }); const text = result.content[0].text; expect(text).toContain("anthropic"); expect(text).toContain("is working"); @@ -1406,7 +1406,7 @@ describe("Tool handlers", () => { registerWorkflowTools(mockServer, mockClient); const handler = tools.get("opencode_provider_test")!; - const result = await handler({ providerId: "badprovider" }); + const result = await handler({ providerId: "badprovider", modelID: "test-model" }); const text = result.content[0].text; expect(text).toContain("FAILED"); expect(text).toContain("badprovider"); @@ -1430,7 +1430,7 @@ describe("Tool handlers", () => { registerWorkflowTools(mockServer, mockClient); const handler = tools.get("opencode_provider_test")!; - const result = await handler({ providerId: "anthropic" }); + const result = await handler({ providerId: "anthropic", modelID: "test-model" }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain("API timeout"); // Should attempt cleanup @@ -1842,9 +1842,10 @@ describe("Tool handlers", () => { "s1": { state: "idle" }, }); if (path.includes("/message")) return Promise.resolve([ - { info: { id: "m1", role: "assistant" }, parts: [{ type: "text", text: "Done!" }] }, + { info: { id: "m1", role: "assistant", time: { created: 1, completed: 2 } }, parts: [{ type: "text", text: "Done!" }] }, ]); - return Promise.resolve({}); + if (path === "/question" || path === "/permission") return Promise.resolve([]); + return Promise.resolve({ id: "s1" }); }); const mockClient = createMockClient({ get: getMock }); const tools = new Map(); @@ -1864,9 +1865,7 @@ describe("Tool handlers", () => { }); it("detects error from object status { state: 'error' }", async () => { - const getMock = vi.fn().mockResolvedValue({ - "s1": { state: "error" }, - }); + const getMock = vi.fn().mockImplementation((path: string) => Promise.resolve(path === "/session/status" ? { s1: { state: "error" } } : [])); const mockClient = createMockClient({ get: getMock }); const tools = new Map(); const mockServer = { @@ -1878,14 +1877,12 @@ describe("Tool handlers", () => { const handler = tools.get("opencode_wait")!; const result = await handler({ sessionId: "s1", timeoutSeconds: 5, pollIntervalMs: 50 }); - expect(result.content[0].text).toContain("error status"); + expect(result.structuredContent.status).toBe("failed"); expect(result.isError).toBe(true); }); it("times out with actionable suggestions", async () => { - const getMock = vi.fn().mockResolvedValue({ - "s1": { state: "running" }, - }); + const getMock = vi.fn().mockImplementation((path: string) => Promise.resolve(path === "/session/status" ? { s1: { state: "running" } } : [])); const mockClient = createMockClient({ get: getMock }); const tools = new Map(); const mockServer = { @@ -1896,21 +1893,23 @@ describe("Tool handlers", () => { registerWorkflowTools(mockServer, mockClient); const handler = tools.get("opencode_wait")!; - const result = await handler({ sessionId: "s1", timeoutSeconds: 1, pollIntervalMs: 200 }); + const result = await handler({ sessionId: "s1", timeoutSeconds: 0.05, pollIntervalMs: 10 }); const text = result.content[0].text; expect(text).toContain("Timeout"); expect(text).toContain("opencode_conversation"); expect(text).toContain("opencode_session_abort"); - expect(result.isError).toBe(true); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent.timedOut).toBe(true); }); it("completes when string status is 'idle'", async () => { const getMock = vi.fn().mockImplementation((path: string) => { if (path === "/session/status") return Promise.resolve({ "s1": "idle" }); if (path.includes("/message")) return Promise.resolve([ - { info: { id: "m1", role: "assistant" }, parts: [{ type: "text", text: "Result" }] }, + { info: { id: "m1", role: "assistant", time: { created: 1, completed: 2 } }, parts: [{ type: "text", text: "Result" }] }, ]); - return Promise.resolve({}); + if (path === "/question" || path === "/permission") return Promise.resolve([]); + return Promise.resolve({ id: "s1" }); }); const mockClient = createMockClient({ get: getMock }); const tools = new Map(); @@ -1926,11 +1925,12 @@ describe("Tool handlers", () => { expect(result.content[0].text).toContain("Session completed"); }); - it("returns 'no messages' when completed but message list is empty", async () => { + it("does not claim completion when no completed assistant message exists", async () => { const getMock = vi.fn().mockImplementation((path: string) => { if (path === "/session/status") return Promise.resolve({ "s1": "completed" }); if (path.includes("/message")) return Promise.resolve([]); - return Promise.resolve({}); + if (path === "/question" || path === "/permission") return Promise.resolve([]); + return Promise.resolve({ id: "s1" }); }); const mockClient = createMockClient({ get: getMock }); const tools = new Map(); @@ -1942,17 +1942,21 @@ describe("Tool handlers", () => { registerWorkflowTools(mockServer, mockClient); const handler = tools.get("opencode_wait")!; - const result = await handler({ sessionId: "s1", timeoutSeconds: 5, pollIntervalMs: 50 }); - expect(result.content[0].text).toContain("no messages"); + const result = await handler({ sessionId: "s1", timeoutSeconds: 0.05, pollIntervalMs: 10 }); + expect(result.structuredContent.status).not.toBe("completed"); + expect(result.structuredContent.timedOut).toBe(true); + expect(result.isError).toBeUndefined(); }); }); describe("opencode_run", () => { it("creates session, sends prompt, and polls until idle", async () => { let pollCount = 0; + let submittedMessageId = ""; const mockClient = createMockClient({ - post: vi.fn().mockImplementation((path: string) => { + post: vi.fn().mockImplementation((path: string, body: any) => { if (path === "/session") return Promise.resolve({ id: "ses-run-1" }); + submittedMessageId = body.messageID; return Promise.resolve({ parts: [{ type: "text", text: "Done building!" }] }); }), get: vi.fn().mockImplementation((path: string) => { @@ -1961,12 +1965,12 @@ describe("Tool handlers", () => { // First poll: running, second poll: idle return Promise.resolve({ "ses-run-1": pollCount >= 2 ? "idle" : "running" }); } - if (path.includes("/message")) return Promise.resolve([{ parts: [{ type: "text", text: "All tasks completed." }] }]); + if (path.includes("/message")) return Promise.resolve([{ info: { id: "answer", role: "assistant", parentID: submittedMessageId, time: { created: 1, completed: 2 } }, parts: [{ type: "text", text: "All tasks completed." }] }]); if (path.includes("/todo")) return Promise.resolve([ { status: "completed", content: "Set up project" }, { status: "completed", content: "Write tests" }, ]); - return Promise.resolve({}); + return Promise.resolve([]); }), }); const tools = new Map(); @@ -1981,18 +1985,19 @@ describe("Tool handlers", () => { const result = await handler({ prompt: "Build app", providerID: "anthropic", modelID: "claude-opus-4-6" }); expect(result.content[0].text).toContain("ses-run-1"); expect(result.content[0].text).toContain("completed"); - expect(result.content[0].text).toContain("2/2"); + expect(result.structuredContent.status).toBe("completed"); + expect(result.structuredContent.messageId).toBe(submittedMessageId); }); it("returns error status when session errors", async () => { const mockClient = createMockClient({ post: vi.fn().mockImplementation((path: string) => { if (path === "/session") return Promise.resolve({ id: "ses-err" }); - return Promise.resolve({}); + return Promise.resolve([]); }), get: vi.fn().mockImplementation((path: string) => { if (path === "/session/status") return Promise.resolve({ "ses-err": "error" }); - return Promise.resolve({}); + return Promise.resolve([]); }), }); const tools = new Map(); @@ -2006,17 +2011,18 @@ describe("Tool handlers", () => { const handler = tools.get("opencode_run")!; const result = await handler({ prompt: "Bad task", maxDurationSeconds: 1 }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain("error"); + expect(result.structuredContent.status).toBe("failed"); }); it("reuses existing session when sessionId provided", async () => { + let submittedMessageId = ""; const mockClient = createMockClient({ - post: vi.fn().mockResolvedValue({}), + post: vi.fn().mockImplementation(async (_path, body) => { submittedMessageId = body.messageID; return undefined; }), get: vi.fn().mockImplementation((path: string) => { if (path === "/session/status") return Promise.resolve({ "existing-ses": "idle" }); - if (path.includes("/message")) return Promise.resolve([{ parts: [{ type: "text", text: "Done" }] }]); + if (path.includes("/message")) return Promise.resolve([{ info: { id: "answer", role: "assistant", parentID: submittedMessageId, time: { created: 1, completed: 2 } }, parts: [{ type: "text", text: "Done" }] }]); if (path.includes("/todo")) return Promise.resolve([]); - return Promise.resolve({}); + return Promise.resolve([]); }), }); const tools = new Map(); @@ -2092,8 +2098,8 @@ describe("Tool handlers", () => { { status: "pending", content: "Write tests" }, ]); if (path.includes("/diff")) return Promise.resolve([{ path: "src/App.tsx" }, { path: "src/index.ts" }]); - if (path === "/session/ses-chk") return Promise.resolve({ title: "Build App", id: "ses-chk" }); - return Promise.resolve({}); + if (path === "/session/ses-chk") return Promise.resolve({ title: "Build App", id: "ses-chk", summary: { files: 2 } }); + return Promise.resolve([]); }), }); const tools = new Map(); @@ -2121,8 +2127,9 @@ describe("Tool handlers", () => { if (path === "/session/status") return Promise.resolve({ "ses-done": "idle" }); if (path.includes("/todo")) return Promise.resolve([]); if (path.includes("/diff")) return Promise.resolve([]); + if (path.includes("/message")) return Promise.resolve([{ info: { id: "answer", role: "assistant", time: { created: 1, completed: 2 } }, parts: [{ type: "text", text: "Done" }] }]); if (path === "/session/ses-done") return Promise.resolve({ title: "Finished", id: "ses-done" }); - return Promise.resolve({}); + return Promise.resolve([]); }), }); const tools = new Map(); @@ -2136,7 +2143,7 @@ describe("Tool handlers", () => { const handler = tools.get("opencode_check")!; const result = await handler({ sessionId: "ses-done" }); const text = result.content[0].text; - expect(text).toContain("idle"); + expect(result.structuredContent.status).toBe("completed"); expect(text).toContain("Done!"); expect(text).toContain("opencode_review_changes"); }); @@ -2146,10 +2153,10 @@ describe("Tool handlers", () => { get: vi.fn().mockImplementation((path: string) => { if (path === "/session/status") return Promise.resolve({ "ses-d": "idle" }); if (path.includes("/todo")) return Promise.resolve([]); - if (path.includes("/message")) return Promise.resolve([{ parts: [{ type: "text", text: "Build complete! All tests pass." }] }]); + if (path.includes("/message")) return Promise.resolve([{ info: { id: "answer", role: "assistant", time: { created: 1, completed: 2 } }, parts: [{ type: "text", text: "Build complete! All tests pass." }] }]); if (path.includes("/diff")) return Promise.resolve([]); if (path === "/session/ses-d") return Promise.resolve({ title: "Detail test", id: "ses-d" }); - return Promise.resolve({}); + return Promise.resolve([]); }), }); const tools = new Map(); @@ -2356,21 +2363,22 @@ describe("Issue #20: asynchronous workflow dispatch", () => { prompt: "Long task", directory: "/tmp", providerID: "test", modelID: "model", variant: "high", agent: "build", maxDurationSeconds: 0, }); - expect(result.isError).toBe(name === "opencode_run" ? true : undefined); + expect(result.isError).toBeUndefined(); expect(result.content[0].text).toContain(name === "opencode_run" ? "still running" : "Task dispatched"); expect(result.content[0].text).toContain("async-session"); expect(client.post).toHaveBeenLastCalledWith("/session/async-session/prompt_async", { - parts: [{ type: "text", text: "Long task" }], noReply: false, + parts: [{ type: "text", text: "Long task" }], messageID: expect.stringMatching(/^msg_/), model: { providerID: "test", modelID: "model" }, variant: "high", agent: "build", - }, { directory: "/tmp" }); + }, expect.objectContaining({ directory: "/tmp", signal: expect.any(AbortSignal), deadline: expect.any(Number) })); }); it("reports dispatch failures rather than claiming a task is running", async () => { const { tools, client } = captureTools(registerWorkflowTools); - vi.mocked(client.post).mockRejectedValue(new Error("dispatch rejected")); + vi.mocked(client.post).mockRejectedValue(new OpenCodeError("dispatch rejected", 400, "POST", "/session/existing/prompt_async", "invalid")); const result = await tools.get("opencode_fire")!.handler({ prompt: "Task", sessionId: "existing" }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain("dispatch rejected"); + expect(result.structuredContent.status).toBe("failed"); + expect(result.content[0].text).toContain("rejected"); }); }); @@ -2379,9 +2387,9 @@ describe("Issue #14: remote project directories", () => { it.each(["opencode_status", "opencode_context", "opencode_check"])("%s accepts a server-only path", async (name) => { const { tools, client } = captureTools(registerWorkflowTools); const directory = "/remote-only/project-issue-14"; - vi.mocked(client.get).mockImplementation(async (path) => path === "/agent" ? [] : {}); + vi.mocked(client.get).mockImplementation(async (path) => path === "/agent" || path === "/question" || path === "/permission" || path.endsWith("/message") ? [] : {}); const result = await tools.get(name)!.handler({ directory, sessionId: "remote-session" }); expect(result.isError).toBeUndefined(); - expect(client.get).toHaveBeenCalledWith(expect.any(String), undefined, directory); + expect(vi.mocked(client.get).mock.calls.some(call => call[2] === directory)).toBe(true); }); }); diff --git a/tests/transport-reliability.test.ts b/tests/transport-reliability.test.ts new file mode 100644 index 0000000..61497ce --- /dev/null +++ b/tests/transport-reliability.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http"; +import { OpenCodeClient, OpenCodeError, OpenCodeSubmissionError } from "../src/client.js"; +import { abortableSleep, createRequestContext } from "../src/async.js"; +import { registerEventTools } from "../src/tools/events.js"; + +const servers: Server[] = []; +async function fixture(handler: (request: IncomingMessage, response: ServerResponse) => void) { + const server = createServer(handler); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as { port: number }).port; + return new OpenCodeClient({ baseUrl: `http://127.0.0.1:${port}` }); +} +afterEach(async () => { + vi.restoreAllMocks(); + for (const server of servers.splice(0)) { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +describe("bounded HTTP requests and safe retries", () => { + it.each(["post", "patch", "put", "delete"] as const)("does not replay %s after accepted request loses response", async (method) => { + let accepted = 0; + const client = await fixture((request) => { + request.resume(); + request.on("end", () => { accepted++; request.socket.destroy(); }); + }); + await expect(client[method]("/session/fixture")).rejects.toMatchObject({ + name: "OpenCodeSubmissionError", submissionOutcome: "unknown", method: method.toUpperCase(), path: "/session/fixture", + }); + expect(accepted).toBe(1); + }); + + it("does not retry a mutation receiving a transient rejection", async () => { + let attempts = 0; + const client = await fixture((_req, res) => { + attempts++; + res.writeHead(503, { "content-type": "application/json" }).end('{"error":"temporarily unavailable"}'); + }); + await expect(client.post("/session")).rejects.toBeInstanceOf(OpenCodeError); + expect(attempts).toBe(1); + }); + + it("retries a safe read after response loss", async () => { + let attempts = 0; + const client = await fixture((req, res) => { + if (++attempts === 1) { req.socket.destroy(); return; } + res.writeHead(200, { "content-type": "application/json" }).end('{"ok":true}'); + }); + await expect(client.get("/session/status")).resolves.toEqual({ ok: true }); + expect(attempts).toBe(2); + }); + + it("retries transient read HTTP statuses", async () => { + let attempts = 0; + const client = await fixture((_req, res) => { + const status = ++attempts === 1 ? 503 : 200; + res.writeHead(status, { "content-type": "application/json" }).end('{"ok":true}'); + }); + await expect(client.get("/session/status")).resolves.toEqual({ ok: true }); + expect(attempts).toBe(2); + }); + + it("cancels retry backoff without submitting another request", async () => { + let attempts = 0; + const controller = new AbortController(); + const client = await fixture((_req, res) => { + attempts++; + res.writeHead(503, { "content-type": "application/json" }).end('{}'); + setTimeout(() => controller.abort(), 40); + }); + await expect(client.get("/session/status", undefined, undefined, { signal: controller.signal })).rejects.toMatchObject({ name: "AbortError" }); + expect(attempts).toBe(1); + }); + + it.each(["get", "post", "patch", "put", "delete"] as const)("bounds stalled %s requests by the shared deadline", async (method) => { + let attempts = 0; + const client = await fixture((req) => { attempts++; req.resume(); }); + const options = { deadline: Date.now() + 100 }; + const started = Date.now(); + const request = method === "post" ? client.post("/session", {}, options) + : client[method]("/session", undefined, undefined, options); + await expect(request).rejects.toSatisfy((error: Error) => + method === "get" ? error.name === "TimeoutError" : error instanceof OpenCodeSubmissionError && (error.cause as Error).name === "TimeoutError"); + expect(Date.now() - started).toBeLessThan(1000); + expect(attempts).toBe(1); + }); + + it("does not send an already-cancelled mutation", async () => { + let attempts = 0; + const client = await fixture((_req, res) => { attempts++; res.end(); }); + const controller = new AbortController(); + controller.abort(); + await expect(client.post("/session", {}, { signal: controller.signal })).rejects.toMatchObject({ name: "AbortError" }); + expect(attempts).toBe(0); + }); + + it("shares one deadline across retries rather than refreshing the timeout", async () => { + let attempts = 0; + const client = await fixture((_req, res) => { + attempts++; + res.writeHead(503, { "content-type": "application/json" }).end('{}'); + }); + const started = Date.now(); + await expect(client.get("/session/status", undefined, undefined, { timeout: 200 })).rejects.toMatchObject({ name: "TimeoutError" }); + expect(Date.now() - started).toBeLessThan(1000); + expect(attempts).toBe(1); + }); + + it("cancels operation sleeps", async () => { + const context = createRequestContext({ timeout: 20 }); + try { await expect(abortableSleep(10_000, context.signal)).rejects.toMatchObject({ name: "TimeoutError" }); } + finally { context.dispose(); } + }); +}); + +describe("SSE lifetime and framing", () => { + it("cancels the HTTP stream when the consumer stops after one event", async () => { + let closed!: () => void; + const didClose = new Promise((resolve) => { closed = resolve; }); + let directory: string | undefined; + const client = await fixture((req, res) => { + directory = req.headers["x-opencode-directory"] as string; + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write('data: first\n\n'); + res.on("close", closed); + }); + for await (const event of client.subscribeSSE("/event", { directory: "/remote/project" })) { + expect(event.data).toBe("first"); + break; + } + await didClose; + expect(directory).toBe("/remote/project"); + }); + + it("preserves multiline/empty data, leading whitespace and split CRLF framing", async () => { + const chunks = ["event: ready\r", "\ndata: first \r\ndata: second\r\n\r", "\ndata:\r\r", "data: unfinished"]; + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(new ReadableStream({ + start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); }, + }))); + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096" }); + const events = []; + for await (const event of client.subscribeSSE("/event")) events.push(event); + expect(events).toEqual([{ event: "ready", data: " first \nsecond" }, { event: "message", data: "" }]); + }); + + it("bounds a stream that never sends data", async () => { + const client = await fixture((_req, res) => { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.flushHeaders(); + }); + await expect((async () => { + for await (const _ of client.subscribeSSE("/event", { timeout: 30 })) { /* no data */ } + })()).rejects.toMatchObject({ name: "TimeoutError" }); + }); +}); + +function eventHandler(client: OpenCodeClient) { + let handler: Function = () => {}; + registerEventTools({ tool: (...args: any[]) => { handler = args.at(-1); } } as any, client); + return handler; +} + +describe("event polling results", () => { + it("reports authentication errors instead of an empty success", async () => { + const client = await fixture((_req, res) => res.writeHead(401).end("fixture unauthorized")); + const result = await eventHandler(client)({ durationMs: 200 }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("401"); + }); + + it("returns partial events alongside connection errors", async () => { + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096" }); + vi.spyOn(client, "subscribeSSE").mockImplementation(async function* () { + yield { event: "message", data: '{"type":"session.busy"}' }; + throw new Error("connection lost"); + }); + const result = await eventHandler(client)({ durationMs: 200 }); + expect(result.isError).toBe(true); + expect(result.structuredContent.partial).toBe(true); + expect(result.structuredContent.events).toHaveLength(1); + expect(result.content.map((item: any) => item.text).join(" ")).toContain("connection lost"); + }); + + it("treats only the local polling deadline as an empty observation", async () => { + const client = await fixture((_req, res) => { res.writeHead(200, { "content-type": "text/event-stream" }); res.flushHeaders(); }); + const result = await eventHandler(client)({ durationMs: 30 }); + expect(result.isError).not.toBe(true); + expect(result.structuredContent.events).toEqual([]); + expect(result.structuredContent.partial).toBe(false); + }); + + it("keeps caller cancellation visible", async () => { + const controller = new AbortController(); + controller.abort(); + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096" }); + const result = await eventHandler(client)({ durationMs: 200 }, { signal: controller.signal }); + expect(result.isError).toBe(true); + }); + + it("supports explicit global scope and rejects a misleading project directory", async () => { + let path = ""; + const client = await fixture((req, res) => { path = req.url!; res.writeHead(200, { "content-type": "text/event-stream" }).end('data: {}\n\n'); }); + const handler = eventHandler(client); + expect((await handler({ scope: "global" })).isError).not.toBe(true); + expect(path).toBe("/global/event"); + expect((await handler({ scope: "global", directory: "/project" })).isError).toBe(true); + }); +}); diff --git a/tests/workflow-reliability.test.ts b/tests/workflow-reliability.test.ts new file mode 100644 index 0000000..3ababb2 --- /dev/null +++ b/tests/workflow-reliability.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { McpServer, type ToolContext } from "../src/mcp-server.js"; +import { OpenCodeClient, OpenCodeError } from "../src/client.js"; +import { registerWorkflowTools } from "../src/tools/workflow.js"; + +const user = (id: string, created = 1) => ({ info: { id, role: "user", time: { created } }, parts: [] }); +const answer = (parentID: string, text: string, error?: unknown) => ({ + info: { id: `answer_${parentID}`, role: "assistant", parentID, time: { created: 2, completed: 3 }, ...(error ? { error } : {}) }, + parts: [{ type: "text", text }], +}); + +function setup() { + const handlers = new Map(); + const server = { tool: (name: string, _description: string, schema: z.ZodRawShape, ...rest: unknown[]) => { + handlers.set(name, { schema, handler: rest.at(-1) as Function }); + } } as unknown as McpServer; + const state = { + status: "idle", messages: [] as unknown[], questions: [] as unknown[], permissions: [] as unknown[], + session: { id: "ses_test", title: "Test", summary: { files: 2 } }, submittedId: "", + }; + const client = { + getBaseUrl: () => "http://127.0.0.1:4096", + get: vi.fn(async (path: string) => { + if (path === "/session/status") return { ses_test: { type: state.status } }; + if (path.endsWith("/message")) return state.messages; + if (path.endsWith("/todo")) return [{ status: "in_progress", content: "Implementing" }]; + if (path === "/question") return state.questions; + if (path === "/permission") return state.permissions; + if (path === "/provider") return { all: [{ id: "test", models: { first: {}, preferred: {} } }], default: { test: "preferred" } }; + return state.session; + }), + post: vi.fn(async (path: string, body?: any) => { + if (path === "/session") return { id: "ses_test" }; + if (path.endsWith("/prompt_async")) { state.submittedId = body.messageID; return undefined; } + return answer("sync", "Fixed the error in validation."); + }), + delete: vi.fn(async () => undefined), + }; + registerWorkflowTools(server, client as unknown as OpenCodeClient); + const call = (name: string, input: unknown, extra?: ToolContext) => { + const entry = handlers.get(name)!; + return entry.handler(z.object(entry.schema).parse(input), extra); + }; + return { client, state, call }; +} + +describe("correlated asynchronous workflow results", () => { + it("does not return an old completed response for a newly dispatched turn", async () => { + vi.useFakeTimers(); + try { + const { state, call } = setup(); + state.messages = [user("old"), answer("old", "Old result")]; + const waiting = call("opencode_run", { prompt: "New task", sessionId: "ses_test", maxDurationSeconds: 1 }); + await vi.advanceTimersByTimeAsync(1001); + const result = await waiting; + expect(result.structuredContent).toMatchObject({ sessionId: "ses_test", messageId: state.submittedId, timedOut: true }); + expect(result.structuredContent.status).not.toBe("completed"); + expect(result.content[0].text).not.toContain("Old result"); + expect(result.isError).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it("reports typed assistant failures even when server status is idle", async () => { + const { state, call } = setup(); + state.messages = [user("current"), answer("current", "", { name: "APIError", data: { message: "Provider unavailable" } })]; + const result = await call("opencode_wait", { sessionId: "ses_test", timeoutSeconds: 1 }); + expect(result.structuredContent.status).toBe("failed"); + expect(result.isError).toBe(true); + expect(result.content[0].text).not.toContain("Session completed"); + }); + + it("ignores older failures once a newer user turn is pending", async () => { + const { state, call } = setup(); + state.messages = [user("old"), answer("old", "", { name: "APIError" }), user("new", 4)]; + const result = await call("opencode_wait", { sessionId: "ses_test", timeoutSeconds: 0.03, pollIntervalMs: 10 }); + expect(result.structuredContent).toMatchObject({ messageId: "new", timedOut: true }); + expect(result.structuredContent.status).not.toBe("failed"); + expect(result.isError).toBeUndefined(); + }); + + it("returns actionable input requests without waiting for timeout", async () => { + const { state, call } = setup(); + state.status = "busy"; + state.questions = [{ id: "question_1", sessionID: "ses_test", questions: [{ question: "Choose a target" }] }]; + const result = await call("opencode_wait", { sessionId: "ses_test", timeoutSeconds: 1 }); + expect(result.structuredContent.status).toBe("input_required"); + expect(result.structuredContent.inputs[0]).toMatchObject({ id: "question_1", kind: "question" }); + expect(result.structuredContent.timedOut).toBeUndefined(); + }); + + it("resumes exact job IDs returned by fire", async () => { + const { state, call } = setup(); + const sent = await call("opencode_fire", { prompt: "Task", directory: "/remote/project" }); + expect(sent.structuredContent.status).toBe("accepted"); + state.messages = [user(state.submittedId), answer(state.submittedId, "Done")]; + const result = await call("opencode_wait", { jobId: sent.structuredContent.jobId, timeoutSeconds: 1 }); + expect(result.structuredContent).toMatchObject({ status: "completed", directory: "/remote/project", messageId: state.submittedId }); + }); + + it("preserves correlation IDs when submission is rejected", async () => { + const { client, call } = setup(); + client.post.mockImplementation(async path => { + if (path === "/session") return { id: "ses_test" }; + throw new OpenCodeError("Rejected", 400, "POST", path, "invalid"); + }); + const result = await call("opencode_fire", { prompt: "Task" }); + expect(result.structuredContent).toMatchObject({ status: "failed", sessionId: "ses_test", jobId: expect.stringMatching(/^job_/), messageId: expect.stringMatching(/^msg_/) }); + expect(result.isError).toBe(true); + }); +}); + +describe("observation lifecycle and cost", () => { + it("cancels sleeping observation promptly without aborting the remote job", async () => { + const { state, client, call } = setup(); + state.status = "busy"; + const controller = new AbortController(); + const waiting = call("opencode_wait", { sessionId: "ses_test", timeoutSeconds: 5, pollIntervalMs: 5000 }, { signal: controller.signal }); + await new Promise(resolve => setTimeout(resolve, 10)); + controller.abort(); + const result = await waiting; + expect(result.structuredContent).toMatchObject({ observationCancelled: true, status: "running" }); + expect(client.post).not.toHaveBeenCalled(); + }); + + it("enforces the wait deadline even if an HTTP adapter never resolves", async () => { + const { client, call } = setup(); + client.get.mockImplementation(() => new Promise(() => {})); + const before = Date.now(); + const result = await call("opencode_wait", { sessionId: "ses_test", timeoutSeconds: 0.03 }); + expect(result.structuredContent).toMatchObject({ status: "unknown", timedOut: true }); + expect(result.isError).toBeUndefined(); + expect(Date.now() - before).toBeLessThan(1000); + expect(client.get.mock.calls[0][3].signal.aborted).toBe(true); + }); + + it("uses summary file counts and excludes full diff bodies from checks", async () => { + const { state, client, call } = setup(); + state.status = "busy"; + const result = await call("opencode_check", { sessionId: "ses_test" }); + expect(result.content[0].text).toContain("Files changed: 2"); + expect(result.content[0].text).toContain("Implementing"); + expect(client.get.mock.calls.some(([path]) => path.endsWith("/diff"))).toBe(false); + }); +}); + +describe("synchronous workflow contracts", () => { + it("preserves session identifiers and format for successful debugging answers", async () => { + const { client, call } = setup(); + const format = { type: "json_schema", schema: { type: "object" } }; + const result = await call("opencode_ask", { prompt: "Fix validation", format }); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent.sessionId).toBe("ses_test"); + expect(client.post.mock.lastCall?.[1]).toMatchObject({ format }); + }); + + it("preserves the created session if a synchronous prompt fails", async () => { + const { client, call } = setup(); + client.post.mockImplementation(async path => { + if (path === "/session") return { id: "ses_test" }; + throw new Error("Request failed"); + }); + const result = await call("opencode_ask", { prompt: "Task" }); + expect(result.isError).toBe(true); + expect(result.structuredContent.sessionId).toBe("ses_test"); + expect(result.content[0].text).toContain("ses_test"); + }); + + it("selects an actual provider model for provider testing", async () => { + const { client, call } = setup(); + const result = await call("opencode_provider_test", { providerId: "test" }); + expect(result.isError).toBeUndefined(); + expect(client.post.mock.lastCall?.[1]).toMatchObject({ model: { providerID: "test", modelID: "preferred" } }); + expect(client.post.mock.lastCall?.[1]).not.toHaveProperty("providerID"); + expect(client.delete).toHaveBeenCalledWith("/session/ses_test", undefined, undefined); + }); +});