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
13 changes: 11 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,14 @@ export class OpenCodeClient {
private authHeader?: string;
private autoServe: boolean;
private reconnectAttempts = 0;
private username?: string;
private password?: string;

constructor(options: OpenCodeClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, "");
this.autoServe = options.autoServe ?? false;
this.username = options.username;
this.password = options.password;
if (options.password) {
const username = options.username ?? "opencode";
this.authHeader =
Expand Down Expand Up @@ -202,9 +206,14 @@ export class OpenCodeClient {
`Connection failed (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}), attempting server reconnection...`,
);
try {
const status = await isServerRunning(this.baseUrl);
const status = await isServerRunning(this.baseUrl, this.username, this.password);
if (!status.healthy) {
await ensureServer({ baseUrl: this.baseUrl, autoServe: true });
await ensureServer({
baseUrl: this.baseUrl,
autoServe: true,
username: this.username,
password: this.password
});
}
// Retry the original request once after reconnection
return this.request<T>(method, path, opts);
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ registerPrompts(server);
async function main() {
// Step 1: Ensure OpenCode server is available (auto-start if needed).
try {
await ensureServer({ baseUrl, autoServe });
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
Expand Down
34 changes: 29 additions & 5 deletions src/server-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export interface ServerManagerOptions {
autoServe?: boolean;
/** Max time (ms) to wait for the server to become healthy after spawning. */
startupTimeoutMs?: number;
/** Username for HTTP basic auth (optional) */
username?: string;
/** Password for HTTP basic auth (optional) */
password?: string;
}

export interface ServerStatus {
Expand All @@ -44,13 +48,23 @@ let shutdownRegistered = false;
*/
export async function isServerRunning(
baseUrl: string,
username?: string,
password?: string,
): Promise<{ healthy: boolean; version?: string }> {
try {
const url = `${baseUrl.replace(/\/$/, "")}/global/health`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3_000);

const headers: Record<string, string> = {};
if (password) {
const user = username ?? "opencode";
headers["Authorization"] = "Basic " + Buffer.from(`${user}:${password}`).toString("base64");
}

const res = await fetch(url, {
method: "GET",
headers,
signal: controller.signal,
});
clearTimeout(timeout);
Comment on lines 55 to 70
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clear the health-check timeout on all paths.

If fetch() throws before Line 70, the abort timer is left pending. This can accumulate timers during repeated polling.

Suggested fix
     const controller = new AbortController();
     const timeout = setTimeout(() => controller.abort(), 3_000);
@@
-    const res = await fetch(url, {
-      method: "GET",
-      headers,
-      signal: controller.signal,
-    });
-    clearTimeout(timeout);
+    let res: Response;
+    try {
+      res = await fetch(url, {
+        method: "GET",
+        headers,
+        signal: controller.signal,
+      });
+    } finally {
+      clearTimeout(timeout);
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const url = `${baseUrl.replace(/\/$/, "")}/global/health`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3_000);
const headers: Record<string, string> = {};
if (password) {
const user = username ?? "opencode";
headers["Authorization"] = "Basic " + Buffer.from(`${user}:${password}`).toString("base64");
}
const res = await fetch(url, {
method: "GET",
headers,
signal: controller.signal,
});
clearTimeout(timeout);
const url = `${baseUrl.replace(/\/$/, "")}/global/health`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3_000);
const headers: Record<string, string> = {};
if (password) {
const user = username ?? "opencode";
headers["Authorization"] = "Basic " + Buffer.from(`${user}:${password}`).toString("base64");
}
let res: Response;
try {
res = await fetch(url, {
method: "GET",
headers,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server-manager.ts` around lines 55 - 70, The abort timeout (variable
timeout) is not cleared if fetch(url, { ..., signal: controller.signal })
throws, leaving timers accumulating; wrap the fetch call in a try...finally (or
ensure a finally block) that calls clearTimeout(timeout) and optionally
controller.abort() cleanup so clearTimeout(timeout) always runs; update the
block around url, controller, timeout and the fetch call to move
clearTimeout(timeout) into the finally to guarantee the timer is cleared on
success or error.

Expand Down Expand Up @@ -132,10 +146,12 @@ function parseBaseUrl(baseUrl: string): { hostname: string; port: number } {
async function waitForHealthy(
baseUrl: string,
timeoutMs: number,
username?: string,
password?: string,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const { healthy } = await isServerRunning(baseUrl);
const { healthy } = await isServerRunning(baseUrl, username, password);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (healthy) return true;
await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL_MS));
}
Expand Down Expand Up @@ -178,6 +194,8 @@ export async function startServer(
binaryPath: string,
baseUrl: string,
timeoutMs: number = DEFAULT_STARTUP_TIMEOUT_MS,
username?: string,
password?: string,
): Promise<{ version?: string }> {
const { hostname, port } = parseBaseUrl(baseUrl);

Expand Down Expand Up @@ -226,7 +244,7 @@ export async function startServer(

// Race: either the server becomes healthy or the child crashes.
const healthy = await Promise.race([
waitForHealthy(baseUrl, timeoutMs),
waitForHealthy(baseUrl, timeoutMs, username, password),
earlyExit.catch(() => false as const),
]);

Expand All @@ -241,7 +259,7 @@ export async function startServer(
}

// Grab the version from the now-running server.
const status = await isServerRunning(baseUrl);
const status = await isServerRunning(baseUrl, username, password);
return { version: status.version };
}

Expand Down Expand Up @@ -271,7 +289,7 @@ export async function ensureServer(
const timeoutMs = opts.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;

// Step 1: Check if server is already running.
const existing = await isServerRunning(baseUrl);
const existing = await isServerRunning(baseUrl, opts.username, opts.password);
if (existing.healthy) {
console.error(
`OpenCode server already running at ${baseUrl} (v${existing.version ?? "unknown"})`,
Expand Down Expand Up @@ -305,7 +323,13 @@ export async function ensureServer(

// Step 3: Start the server.
console.error(`Starting: opencode serve --port ${parseBaseUrl(baseUrl).port}`);
const result = await startServer(binaryPath, baseUrl, timeoutMs);
const result = await startServer(
binaryPath,
baseUrl,
timeoutMs,
opts.username,
opts.password,
);
console.error(
`OpenCode server started successfully (v${result.version ?? "unknown"})`,
);
Expand Down
118 changes: 116 additions & 2 deletions tests/server-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,40 @@ describe("isServerRunning", () => {
expect(result).toEqual({ healthy: true, version: "1.2.0" });
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:4096/global/health",
expect.objectContaining({ method: "GET" }),
expect.objectContaining({
method: "GET",
headers: {}
}),
);
});

it("includes authorization header when password is provided", async () => {
mockFetchHealthy("1.2.0");
const result = await isServerRunning("http://127.0.0.1:4096", "admin", "secret123");
expect(result).toEqual({ healthy: true, version: "1.2.0" });
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:4096/global/health",
expect.objectContaining({
method: "GET",
headers: {
"Authorization": "Basic YWRtaW46c2VjcmV0MTIz"
}
}),
);
});

it("uses default username 'opencode' when password provided without username", async () => {
mockFetchHealthy("1.2.0");
const result = await isServerRunning("http://127.0.0.1:4096", undefined, "secret123");
expect(result).toEqual({ healthy: true, version: "1.2.0" });
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:4096/global/health",
expect.objectContaining({
method: "GET",
headers: {
"Authorization": "Basic b3BlbmNvZGU6c2VjcmV0MTIz"
}
}),
);
});

Expand All @@ -136,7 +169,10 @@ describe("isServerRunning", () => {
await isServerRunning("http://127.0.0.1:4096/");
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:4096/global/health",
expect.anything(),
expect.objectContaining({
method: "GET",
headers: {}
}),
);
});

Expand Down Expand Up @@ -231,6 +267,84 @@ describe("ensureServer", () => {
);
});

it("includes authorization header when checking server with auth", async () => {
mockFetchHealthy("1.2.0");
const result = await ensureServer({
baseUrl: "http://127.0.0.1:4096",
username: "admin",
password: "secret123",
});
expect(result).toEqual({
running: true,
version: "1.2.0",
managedByUs: false,
});
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:4096/global/health",
expect.objectContaining({
method: "GET",
headers: {
"Authorization": "Basic YWRtaW46c2VjcmV0MTIz"
}
}),
);
});

it("uses authorization headers during auto-start health polling", async () => {
const expectedAuth = "Basic YWRtaW46c2VjcmV0MTIz";
const fakeChild = createFakeChild();
spawnMock.mockReturnValueOnce(fakeChild as any);

let healthCheckCalls = 0;
fetchMock.mockImplementation(async (_url: string, init?: RequestInit) => {
healthCheckCalls += 1;

if (healthCheckCalls === 1) {
throw new Error("ECONNREFUSED");
}

const headers = (init?.headers ?? {}) as Record<string, string>;
if (headers.Authorization !== expectedAuth) {
return {
ok: true,
status: 200,
json: async () => ({ healthy: false }),
} as unknown as Response;
}

return {
ok: true,
status: 200,
json: async () => ({ healthy: true, version: "1.1.53" }),
} as unknown as Response;
});

mockExecFileSuccess("/usr/local/bin/opencode\n");
mockExecFileSuccess("1.1.53\n");

const result = await ensureServer({
baseUrl: "http://127.0.0.1:4096",
startupTimeoutMs: 1_000,
username: "admin",
password: "secret123",
});

expect(result).toEqual({
running: true,
version: "1.1.53",
managedByUs: true,
});
for (const [, init] of fetchMock.mock.calls.slice(1)) {
expect(init).toEqual(
expect.objectContaining({
headers: {
Authorization: expectedAuth,
},
}),
);
}
});

it("throws when auto-serve is disabled and server is not running", async () => {
mockFetchDown();
await expect(
Expand Down