Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ 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]

### Added

- Bearer-token authentication with `OPENCODE_SERVER_TOKEN`.
- OpenChamber sign-in with `OPENCODE_OPENCHAMBER_PASSWORD` (and optional `OPENCODE_OPENCHAMBER_URL`): exchanges the UI password for a client token, keeps it in memory, and renews it once on 401. Applies to API requests, event streams and the health probe. Each process gets a distinct client identifier by default (override with `OPENCODE_OPENCHAMBER_CLIENT_ID`), so independent MCP instances on the same host do not replace each other's token.

### Fixed

- Event streams (`opencode_events_poll`) preserved the `OPENCODE_BASE_URL` path prefix (e.g. OpenChamber's `/api` proxy) instead of resolving against the origin alone.
- The smoke-test runner now forwards `OPENCODE_SERVER_TOKEN`, `OPENCODE_OPENCHAMBER_PASSWORD`, `OPENCODE_OPENCHAMBER_URL` and `OPENCODE_OPENCHAMBER_CLIENT_ID` to the smoke child.

## [3.0.0] - 2026-09-16

### Breaking changes
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ All settings are optional; an OpenCode server must already be running by default
|---|---|
| `OPENCODE_BASE_URL` | Server endpoint; defaults to `http://127.0.0.1:4096` |
| `OPENCODE_SERVER_USERNAME`, `OPENCODE_SERVER_PASSWORD` | Optional server HTTP authentication |
| `OPENCODE_SERVER_TOKEN` | Optional bearer token, e.g. an OpenChamber client token |
| `OPENCODE_OPENCHAMBER_PASSWORD`, `OPENCODE_OPENCHAMBER_URL` | Sign in to an [OpenChamber](https://github.com/openchamber/openchamber)-fronted server |
| `OPENCODE_OPENCHAMBER_CLIENT_ID` | Overrides the per-process OpenChamber client identifier; see [configuration](docs/configuration.md#openchamber) |
| `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 |
Expand Down
18 changes: 18 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ An OpenCode server must already be running unless automatic startup is explicitl
| `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_SERVER_TOKEN` | unset | Bearer token sent as `Authorization: Bearer <token>`; replaces Basic auth |
| `OPENCODE_OPENCHAMBER_PASSWORD` | unset | OpenChamber UI password, exchanged for a client token; see below |
| `OPENCODE_OPENCHAMBER_URL` | origin of `OPENCODE_BASE_URL` | OpenChamber origin that serves `/auth/session` |
| `OPENCODE_OPENCHAMBER_CLIENT_ID` | random per process | Overrides the identifier that scopes this instance's OpenChamber client record; see below |
| `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 |
Expand All @@ -21,6 +25,20 @@ The `essential` profile keeps the common delegation, observation, and required-i

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.

## OpenChamber

[OpenChamber](https://github.com/openchamber/openchamber) proxies OpenCode under `/api` and rejects Basic auth. Point `OPENCODE_BASE_URL` at the proxy and choose one credential:

- `OPENCODE_OPENCHAMBER_PASSWORD`: on first use, opencode-mcp posts the UI password to `/auth/session` and receives a trusted-device client token (7 days by default). The token is kept in memory only. When a request is rejected with 401, it signs in once more and resends that request; the rejected request never reached OpenCode, so writes are not duplicated. Each MCP process gets its own client entry in OpenChamber's client list, keyed by a random identifier generated at startup, so independent MCP instances on the same host (e.g. two editor windows) never replace each other's token. Set `OPENCODE_OPENCHAMBER_CLIENT_ID` to a stable value only if you deliberately want multiple processes to share one client entry.
- `OPENCODE_SERVER_TOKEN`: a client token you minted yourself. It is not renewed; replace it when it expires or is revoked.

```bash
OPENCODE_BASE_URL=https://openchamber.example.com/api
OPENCODE_OPENCHAMBER_PASSWORD=... # keep in your MCP client's secret store
```

OpenChamber rate-limits sign-ins (10 attempts per 5 minutes by default, then a lockout); a wrong password reports an error instead of retrying. Treat the password and tokens as secrets: they grant the same access as the OpenChamber UI.

## Project Scope

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`.
Expand Down
9 changes: 8 additions & 1 deletion scripts/mcp-smoke-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,14 @@ export function smokeOptions(argv = [], env = process.env) {

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"]) {
for (const key of [
"OPENCODE_SERVER_USERNAME",
"OPENCODE_SERVER_PASSWORD",
"OPENCODE_SERVER_TOKEN",
"OPENCODE_OPENCHAMBER_PASSWORD",
"OPENCODE_OPENCHAMBER_URL",
"OPENCODE_OPENCHAMBER_CLIENT_ID",
]) {
if (env[key] !== undefined) childEnv[key] = env[key];
}
return childEnv;
Expand Down
148 changes: 148 additions & 0 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { hostname } from "node:os";
import { randomUUID } from "node:crypto";

export interface AuthOptions {
/** HTTP Basic credentials for an OpenCode server started with OPENCODE_SERVER_PASSWORD. */
username?: string;
password?: string;
/** Static bearer token, sent as `Authorization: Bearer <token>`. */
token?: string;
/** OpenChamber UI password, exchanged for a bearer client token. */
openchamberPassword?: string;
/** OpenChamber origin hosting `/auth/session`; defaults to the base URL's origin. */
openchamberUrl?: string;
/**
* Overrides the identifier that scopes this instance's OpenChamber
* `dedupeKey` (`OPENCODE_OPENCHAMBER_CLIENT_ID`). Set this when two MCP
* instances must intentionally share one client record, e.g. across
* restarts; otherwise a random one is generated per process.
*/
clientId?: string;
}

const LOGIN_TIMEOUT_MS = 15_000;

/**
* Resolves the `Authorization` header for OpenCode requests.
*
* Precedence: OpenChamber login, then a static bearer token, then Basic auth.
* An OpenChamber client token is obtained lazily, cached in memory only, and
* renewed once when a request is rejected with 401.
*/
export class AuthProvider {
private token?: string;
private login?: Promise<string>;

/**
* Identifies this MCP instance to OpenChamber. Stable for the lifetime of
* the provider (and thus reused for every renewal), but unique per
* instance so a second independent MCP instance on the same host cannot
* replace this one's client record: OpenChamber v2.4.0 revokes the
* previous client record whenever a login reuses a `dedupeKey` (see
* https://github.com/openchamber/openchamber/blob/v2.4.0/packages/web/
* server/lib/client-auth/remote-clients.js#L188-L200), so a key derived
* only from `hostname()` let a second MCP process silently invalidate the
* first one's cached token. Override with `OPENCODE_OPENCHAMBER_CLIENT_ID`
* when two instances must intentionally share one client record, e.g.
* across restarts of a supervised process that should keep one slot.
*/
private readonly instanceId: string;

constructor(private readonly options: AuthOptions, private readonly baseUrl: string) {
this.token = options.openchamberPassword ? undefined : options.token || undefined;
this.instanceId = options.clientId || `${process.pid}-${randomUUID()}`;
}

/** True when a rejected token can be renewed without user action. */
get canRefresh(): boolean {
return Boolean(this.options.openchamberPassword);
}

async getHeader(): Promise<string | undefined> {
if (this.options.openchamberPassword) {
return `Bearer ${this.token ?? await this.signIn()}`;
}
if (this.token) return `Bearer ${this.token}`;
const { username, password } = this.options;
if (!password) return undefined;
return "Basic " + Buffer.from(`${username ?? "opencode"}:${password}`).toString("base64");
}

/**
* Discard the rejected header and sign in again. Concurrent callers that
* observed the same rejected header share one login.
*/
async refresh(rejected: string | undefined): Promise<string | undefined> {
if (!this.canRefresh) return undefined;
if (rejected && rejected !== `Bearer ${this.token}` && this.token) return `Bearer ${this.token}`;
this.token = undefined;
return `Bearer ${await this.signIn()}`;
}

private signIn(): Promise<string> {
this.login ??= this.requestClientToken().then(
(token) => { this.token = token; return token; },
).finally(() => { this.login = undefined; });
return this.login;
}

private async requestClientToken(): Promise<string> {
const origin = new URL(this.options.openchamberUrl || this.baseUrl).origin;
const host = hostname();
let res: Response;
try {
res = await fetch(`${origin}/auth/session`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
password: this.options.openchamberPassword,
issueClientToken: true,
trustDevice: true,
clientLabel: `opencode-mcp (${host})`,
clientKind: "desktop",
// Scoped to this instance, not just this host: see `instanceId` above.
dedupeKey: `opencode-mcp:${host}:${this.instanceId}`,
}),
signal: AbortSignal.timeout(LOGIN_TIMEOUT_MS),
});
} catch (error) {
throw new Error(`OpenChamber login to ${origin} failed: ${error instanceof Error ? error.message : String(error)}`);
}
if (res.status === 429) {
const retryAfter = res.headers.get("retry-after");
throw new Error(`OpenChamber login to ${origin} is rate limited${retryAfter ? `; retry after ${retryAfter}s` : ""}`);
}
if (res.status === 401) {
throw new Error(`OpenChamber login to ${origin} was rejected; check OPENCODE_OPENCHAMBER_PASSWORD`);
}
if (!res.ok) throw new Error(`OpenChamber login to ${origin} failed (${res.status})`);
const body = await res.json().catch(() => undefined) as { clientToken?: unknown } | undefined;
if (typeof body?.clientToken !== "string" || !body.clientToken) {
throw new Error(`OpenChamber at ${origin} did not issue a client token; upgrade OpenChamber or set OPENCODE_SERVER_TOKEN`);
}
return body.clientToken;
}

/**
* `fetch` that applies the current header and, for renewable credentials,
* retries once after a 401. The rejected request never reached OpenCode, so
* resending it cannot duplicate a write.
*/
async fetch(request: Request): Promise<Response> {
const header = await this.getHeader();
const retry = this.canRefresh ? request.clone() : undefined;
const response = await fetch(withAuthorization(request, header));
if (response.status !== 401 || !retry) return response;
const renewed = await this.refresh(header);
if (!renewed) return response;
await response.body?.cancel().catch(() => {});
return fetch(withAuthorization(retry, renewed));
}
}

function withAuthorization(request: Request, header: string | undefined): Request {
if (!header) return request;
const headers = new Headers(request.headers);
headers.set("Authorization", header);
return new Request(request, { headers });
}
44 changes: 26 additions & 18 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { createOpencodeClient, OpencodeClient as NativeClient } from "@opencode-ai/sdk";
import { normalizeDirectory } from "./helpers.js";
import { ensureServer, isServerRunning } from "./server-manager.js";
import { AuthProvider } from "./auth.js";
import { createRequestContext, abortableSleep, throwIfAborted, withAbort, type RequestOptions } from "./async.js";
export type { RequestOptions } from "./async.js";

export interface OpenCodeClientOptions {
baseUrl: string;
username?: string;
password?: string;
/** Static bearer token (`OPENCODE_SERVER_TOKEN`). */
token?: string;
/** OpenChamber UI password (`OPENCODE_OPENCHAMBER_PASSWORD`). */
openchamberPassword?: string;
/** OpenChamber origin (`OPENCODE_OPENCHAMBER_URL`). */
openchamberUrl?: string;
/** Overrides the per-process OpenChamber dedupe scope (`OPENCODE_OPENCHAMBER_CLIENT_ID`). */
clientId?: string;
autoServe?: boolean;
}

Expand Down Expand Up @@ -66,25 +75,21 @@ function isConnectionError(err: Error): boolean {
);
}

function buildBasicAuthHeader(username?: string, password?: string): string | undefined {
if (!password) return undefined;
const user = username ?? "opencode";
return "Basic " + Buffer.from(`${user}:${password}`).toString("base64");
}

export class OpenCodeClient {
public api: NativeClient;
private baseUrl: string;
private autoServe: boolean;
private reconnectAttempts = 0;
private username?: string;
private password?: string;
private auth: AuthProvider;

constructor(options: OpenCodeClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, "");
this.autoServe = options.autoServe ?? false;
this.username = options.username;
this.password = options.password;
this.auth = new AuthProvider(options, this.baseUrl);

this.api = this.buildSdkClient();
}
Expand All @@ -95,18 +100,18 @@ export class OpenCodeClient {
* surfaces a different URL than the one we were aiming at.
*/
private buildSdkClient(): NativeClient {
const headers: Record<string, string> = {};
const authHeader = buildBasicAuthHeader(this.username, this.password);
if (authHeader) {
headers["Authorization"] = authHeader;
}
return createOpencodeClient({ baseUrl: this.baseUrl, headers });
return createOpencodeClient({ baseUrl: this.baseUrl, fetch: (request: Request) => this.auth.fetch(request) });
}

getBaseUrl(): string {
return this.baseUrl;
}

/** Current `Authorization` header value; signs in to OpenChamber when configured. */
getAuthorization(): Promise<string | undefined> {
return this.auth.getHeader();
}

private async request<T = unknown>(
method: string,
path: string,
Expand Down Expand Up @@ -159,11 +164,12 @@ export class OpenCodeClient {
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);
const authorization = await withAbort(this.auth.getHeader(), context.signal);
const status = await withAbort(isServerRunning(this.baseUrl, this.username, this.password, authorization), context.signal);
if (!status.healthy) {
throwIfAborted(context.signal);
const ensured = await withAbort(ensureServer({
baseUrl: this.baseUrl, autoServe: true, username: this.username, password: this.password,
baseUrl: this.baseUrl, autoServe: true, username: this.username, password: this.password, authorization,
}), context.signal);
if (ensured.url) {
const nextUrl = ensured.url.replace(/\/$/, "");
Expand Down Expand Up @@ -212,18 +218,20 @@ export class OpenCodeClient {
}

async *subscribeSSE(path: string, opts: RequestOptions & { directory?: string } = {}): AsyncGenerator<{ event: string; data: string }, void, undefined> {
const url = new URL(path, this.baseUrl).toString();
// `new URL(path, this.baseUrl)` resolves an absolute `path` against the
// origin, discarding any base path (e.g. OpenChamber's `/api` proxy
// prefix). Join on the string form of baseUrl instead, which already has
// its trailing slash stripped in the constructor.
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = { 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 context = createRequestContext(opts, 30_000);
let reader: ReadableStreamDefaultReader<Uint8Array> | 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);
const res = await withAbort(this.auth.fetch(new Request(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);
Expand Down
12 changes: 9 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,21 @@ async function main() {
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 token = process.env.OPENCODE_SERVER_TOKEN || undefined;
const openchamberPassword = process.env.OPENCODE_OPENCHAMBER_PASSWORD || undefined;
const openchamberUrl = process.env.OPENCODE_OPENCHAMBER_URL || undefined;
const clientId = process.env.OPENCODE_OPENCHAMBER_CLIENT_ID || undefined;
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();
const client = new OpenCodeClient({ baseUrl, username, password, autoServe });
const scope = createHash("sha256").update(JSON.stringify([baseUrl, username ?? "opencode", password ?? ""])).digest("hex");
const client = new OpenCodeClient({ baseUrl, username, password, token, openchamberPassword, openchamberUrl, clientId, autoServe });
const identity: unknown[] = [baseUrl, username ?? "opencode", password ?? ""];
if (token || openchamberPassword) identity.push(token ?? "", openchamberPassword ?? "", openchamberUrl ?? "");
const scope = createHash("sha256").update(JSON.stringify(identity)).digest("hex");
const jobs = new JobService(client, { storeRoot: process.env.OPENCODE_TASK_STORE, scope });
try { await ensureServer({ baseUrl, autoServe, username, password }); }
try { await ensureServer({ baseUrl, autoServe, username, password, authorization: await client.getAuthorization() }); }
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),
Expand Down
Loading
Loading