From bd41d7752cb198aabb90e6bb74f71c5c3eeb5288 Mon Sep 17 00:00:00 2001 From: aiedwardyi <41576951+aiedwardyi@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:49:46 +0900 Subject: [PATCH 1/4] feat: add MCP bridge --- bridge.js | 470 ++++++++++++++++++++++++++++++++++++++++++++++ config.json | 5 +- public/index.html | 163 +++++++++++++++- src/daemon.js | 160 +++++++++++++++- src/store.js | 6 + 5 files changed, 798 insertions(+), 6 deletions(-) create mode 100644 bridge.js diff --git a/bridge.js b/bridge.js new file mode 100644 index 0000000..65c24f5 --- /dev/null +++ b/bridge.js @@ -0,0 +1,470 @@ +const http = require('node:http'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = __dirname; +const statePath = path.join(root, 'state'); +const logsPath = path.join(statePath, 'logs'); +const configPath = path.join(root, 'config.json'); +const heartbeatPath = path.join(statePath, 'bridge.json'); +const tokenPath = path.join(statePath, 'mcp-token'); +const protocolVersion = '2025-06-18'; +const supportedVersions = new Set(['2024-11-05', '2025-03-26', protocolVersion]); +const maxBodyBytes = 1000000; + +let bridgeToken; +let heartbeatTimer; +let server; +let startedAt; +let shuttingDown = false; + +function loadConfig() { + try { + return JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch { + return {}; + } +} + +function bridgePort() { + const port = Number(loadConfig().mcpBridge?.port); + + if (port !== 5758) { + return 5758; + } + + return port; +} + +function dashboardPort() { + const port = Number(loadConfig().dashboardPort); + return Number.isInteger(port) && port > 0 && port < 65536 ? port : 5757; +} + +function log(message) { + try { + fs.mkdirSync(logsPath, { recursive: true }); + fs.appendFileSync(path.join(logsPath, 'bridge.log'), `${new Date().toISOString()} ${message}\n`, 'utf8'); + } catch { + } +} + +function readToken() { + try { + const token = fs.readFileSync(tokenPath, 'utf8').trim(); + + if (token) { + return token; + } + } catch { + } + + const token = crypto.randomBytes(32).toString('hex'); + fs.mkdirSync(statePath, { recursive: true }); + + try { + fs.writeFileSync(tokenPath, `${token}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); + return token; + } catch (error) { + if (error.code === 'EEXIST') { + return fs.readFileSync(tokenPath, 'utf8').trim(); + } + throw error; + } +} + +function writeHeartbeat() { + fs.mkdirSync(statePath, { recursive: true }); + fs.writeFileSync(heartbeatPath, `${JSON.stringify({ + pid: process.pid, + port: bridgePort(), + startedAt, + ts: new Date().toISOString(), + }, null, 2)}\n`, 'utf8'); +} + +function clearHeartbeat() { + try { + const heartbeat = JSON.parse(fs.readFileSync(heartbeatPath, 'utf8')); + + if (heartbeat.pid !== process.pid) { + return; + } + } catch { + } + + try { + fs.unlinkSync(heartbeatPath); + } catch { + } +} + +function sendJson(res, statusCode, value) { + const body = JSON.stringify(value); + res.writeHead(statusCode, { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + 'content-length': Buffer.byteLength(body), + }); + res.end(body); +} + +function sendEmpty(res, statusCode, headers = {}) { + res.writeHead(statusCode, { 'cache-control': 'no-store', ...headers }); + res.end(); +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let size = 0; + let body = ''; + + req.setEncoding('utf8'); + req.on('data', (chunk) => { + size += Buffer.byteLength(chunk); + + if (size > maxBodyBytes) { + reject(new Error('Request body is too large.')); + req.destroy(); + return; + } + + body += chunk; + }); + req.on('end', () => { + try { + resolve(JSON.parse(body)); + } catch { + reject(new Error('Request body must be valid JSON.')); + } + }); + req.on('error', reject); + }); +} + +function rpcResult(id, result) { + return { jsonrpc: '2.0', id, result }; +} + +function rpcError(id, code, message) { + return { jsonrpc: '2.0', id, error: { code, message } }; +} + +function timingSafeEqual(left, right) { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + + return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function authorized(req, requestUrl) { + const header = req.headers.authorization; + const match = typeof header === 'string' ? /^Bearer\s+(.+)$/i.exec(header) : null; + const candidate = match ? match[1].trim() : requestUrl.searchParams.get('key') || ''; + + return timingSafeEqual(candidate, bridgeToken); +} + +function daemonRequest(requestPath, body) { + return new Promise((resolve, reject) => { + const data = body === undefined ? null : Buffer.from(JSON.stringify(body)); + const request = http.request({ + hostname: '127.0.0.1', + port: dashboardPort(), + path: requestPath, + method: data ? 'POST' : 'GET', + headers: data ? { + 'content-type': 'application/json', + 'content-length': data.length, + } : {}, + }, (response) => { + let text = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { text += chunk; }); + response.on('end', () => { + let value = null; + + try { + value = text ? JSON.parse(text) : null; + } catch { + value = null; + } + + resolve({ statusCode: response.statusCode || 500, value }); + }); + }); + + request.setTimeout(10000, () => request.destroy(new Error('Daemon request timed out.'))); + request.on('error', reject); + request.end(data || undefined); + }); +} + +function numeric(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function statusSnapshot(state) { + const stats = state && typeof state.stats === 'object' ? state.stats : {}; + const tasks = state && typeof state.tasks === 'object' ? state.tasks : {}; + const running = Array.isArray(tasks.running) ? tasks.running : []; + const recent = Array.isArray(tasks.recent) ? tasks.recent : []; + + return { + daemonAlive: state?.daemon?.alive === true, + counts: { + pending: numeric(stats.pending), + running: numeric(stats.running), + done: numeric(stats.done), + failed: numeric(stats.failed), + }, + runningTasks: running.map((task) => ({ + id: task.id, + title: task.title, + elapsed: numeric(task.elapsedMs), + })), + recentResults: recent.map((task) => ({ + id: task.id, + title: task.title, + status: task.status || task.result?.status || null, + })), + }; +} + +function toolResult(value) { + return { + content: [{ type: 'text', text: JSON.stringify(value) }], + structuredContent: value, + }; +} + +function toolFailure(message) { + return { + content: [{ type: 'text', text: message }], + isError: true, + }; +} + +const tools = [ + { + name: 'agentloop_status', + description: 'Read the current AgentLoop status.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + annotations: { readOnlyHint: true }, + }, + { + name: 'dispatch_task', + description: 'Dispatch a task to AgentLoop.', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string' }, + prompt: { type: 'string' }, + engine: { type: 'string', enum: ['codex'] }, + }, + required: ['title', 'prompt'], + additionalProperties: false, + }, + annotations: { readOnlyHint: false }, + }, + { + name: 'start_loop', + description: 'Start an AgentLoop project loop.', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string' }, + maxCycles: { type: 'integer', minimum: 1, maximum: 10 }, + engine: { type: 'string', enum: ['codex'] }, + }, + required: ['project'], + additionalProperties: false, + }, + annotations: { readOnlyHint: false }, + }, +]; + +function daemonError(response) { + return response.value && typeof response.value.error === 'string' + ? response.value.error + : `Daemon request failed (${response.statusCode}).`; +} + +async function callTool(params) { + const name = params && typeof params.name === 'string' ? params.name : ''; + const args = params && params.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments) + ? params.arguments + : {}; + + if (name === 'agentloop_status') { + try { + const response = await daemonRequest('/api/state'); + return toolResult(statusSnapshot(response.statusCode === 200 ? response.value : null)); + } catch { + return toolResult(statusSnapshot(null)); + } + } + + if (name === 'dispatch_task') { + const response = await daemonRequest('/api/dispatch', { + title: args.title, + prompt: args.prompt, + engine: args.engine, + source: 'mcp', + }); + + return response.statusCode >= 200 && response.statusCode < 300 + ? toolResult({ id: response.value?.id }) + : toolFailure(daemonError(response)); + } + + if (name === 'start_loop') { + const response = await daemonRequest('/api/loop', { + project: args.project, + maxCycles: args.maxCycles, + engine: args.engine, + source: 'mcp', + }); + + return response.statusCode >= 200 && response.statusCode < 300 + ? toolResult({ id: response.value?.id }) + : toolFailure(daemonError(response)); + } + + return toolFailure(`Unknown tool: ${name}.`); +} + +async function handleMessage(message) { + if (!message || typeof message !== 'object' || Array.isArray(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') { + return rpcError(null, -32600, 'Invalid Request.'); + } + + const notification = !Object.hasOwn(message, 'id'); + const id = notification ? null : message.id; + let result; + + try { + if (message.method === 'initialize') { + const requestedVersion = message.params?.protocolVersion; + result = { + protocolVersion: supportedVersions.has(requestedVersion) ? requestedVersion : protocolVersion, + capabilities: { tools: {}, resources: {}, prompts: {} }, + serverInfo: { name: 'agentloop', version: '0.1.0' }, + }; + } else if (message.method === 'notifications/initialized') { + result = {}; + } else if (message.method === 'tools/list') { + result = { tools }; + } else if (message.method === 'tools/call') { + result = await callTool(message.params); + } else if (message.method === 'resources/list') { + result = { resources: [] }; + } else if (message.method === 'prompts/list') { + result = { prompts: [] }; + } else { + return notification ? null : rpcError(id, -32601, 'Method not found.'); + } + } catch (error) { + return notification ? null : rpcError(id, -32603, error.message || 'Internal error.'); + } + + return notification ? null : rpcResult(id, result); +} + +async function handleRequest(req, res) { + const requestUrl = new URL(req.url || '/', 'http://127.0.0.1'); + + if (!['/', '/mcp'].includes(requestUrl.pathname)) { + sendJson(res, 404, { error: 'Not found.' }); + return; + } + + if (req.method !== 'POST') { + sendEmpty(res, 405, { allow: 'POST' }); + return; + } + + if (!authorized(req, requestUrl)) { + sendJson(res, 401, rpcError(null, -32001, 'Unauthorized.')); + return; + } + + let payload; + + try { + payload = await readJsonBody(req); + } catch (error) { + sendJson(res, 400, rpcError(null, -32700, error.message)); + return; + } + + const batch = Array.isArray(payload); + + if (batch && payload.length === 0) { + sendJson(res, 400, rpcError(null, -32600, 'Invalid Request.')); + return; + } + + const messages = batch ? payload : [payload]; + const responses = []; + + for (const message of messages) { + const response = await handleMessage(message); + + if (response) { + responses.push(response); + } + } + + if (!responses.length) { + sendEmpty(res, 202); + return; + } + + sendJson(res, 200, batch ? responses : responses[0]); +} + +function shutdown() { + if (shuttingDown) { + return; + } + + shuttingDown = true; + clearInterval(heartbeatTimer); + clearHeartbeat(); + log('stopped'); + + if (!server) { + process.exit(0); + return; + } + + server.close(() => process.exit(0)); +} + +function start() { + bridgeToken = readToken(); + startedAt = new Date().toISOString(); + writeHeartbeat(); + server = http.createServer((req, res) => { + handleRequest(req, res).catch((error) => { + sendJson(res, 500, rpcError(null, -32603, error.message || 'Internal error.')); + }); + }); + server.on('error', (error) => { + clearHeartbeat(); + log(`error: ${error.message}`); + process.exitCode = 1; + }); + server.listen(bridgePort(), '127.0.0.1', () => { + writeHeartbeat(); + heartbeatTimer = setInterval(writeHeartbeat, 5000); + log('started'); + }); +} + +process.once('SIGINT', shutdown); +process.once('SIGTERM', shutdown); +start(); diff --git a/config.json b/config.json index 8e5dd76..0653ab9 100644 --- a/config.json +++ b/config.json @@ -2,5 +2,8 @@ "dashboardPort": 5757, "maxConcurrent": 2, "taskTimeoutMin": 45, - "defaultEngine": "codex" + "defaultEngine": "codex", + "mcpBridge": { + "port": 5758 + } } diff --git a/public/index.html b/public/index.html index f9829a5..5d48625 100644 --- a/public/index.html +++ b/public/index.html @@ -176,6 +176,28 @@ .chip b{font-family:var(--mono);font-weight:650;color:var(--text);font-variant-numeric:tabular-nums;font-size:12px} .hright{display:flex;align-items:center;gap:10px} #clock{font-family:var(--mono);font-size:12.5px;color:var(--text);letter-spacing:.04em;font-variant-numeric:tabular-nums} +/* connector */ +.connector{position:relative} +#connectorBtn{ + display:inline-flex;align-items:center;gap:6px;padding:4px 10px; + border:1px solid var(--border);background:var(--hover);color:var(--text); +} +#connectorBtn:hover{border-color:var(--accent-soft);background:var(--accent-dim)} +#connectorBtn i{width:6px;height:6px;border-radius:2px;background:var(--muted);flex:none} +#connectorBtn.live i{background:var(--ok);animation:breathe 1.8s ease-in-out infinite} +#connectorMenu{ + position:absolute;right:0;top:calc(100% + 10px);z-index:60;width:390px;max-width:calc(100vw - 24px); + background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow); +} +#connectorMenu[hidden]{display:none} +#connectorMenu:not([hidden]){animation:menuIn .16s ease} +.mcp-pophead{ + display:flex;align-items:center;gap:8px;padding:9px 12px;border-bottom:1px solid var(--border); + background:color-mix(in srgb,var(--text) 2.5%,transparent); +} +.mcp-poplabel{font:650 10.5px/1.4 var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--muted)} +.mcp-pophead .spacer{flex:1} +.mcp-popbody{padding:8px} /* alive pill */ #pill{ display:inline-flex;align-items:center;gap:7px;padding:4px 10px;border-radius:5px; @@ -285,6 +307,14 @@ .chead .spacer{flex:1} .cbody{padding:8px} .cbody.pad{padding:14px 16px 16px} +/* ================= connector ================= */ +.mcp-row{display:flex;align-items:center;gap:8px;padding:7px 8px;border-bottom:1px solid var(--line-soft)} +.mcp-row:last-of-type{border-bottom:none} +.mcp-label{width:68px;flex:none;font:600 10px/1.4 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted)} +.mcp-value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:400 11px/1.5 var(--mono);color:var(--text)} +.mcp-token{color:var(--accent)} +.mcp-hint{padding:8px;color:var(--muted);font-size:11.5px;line-height:1.5} +.mcpbadge{margin-left:2px;color:var(--info);background:color-mix(in srgb,var(--info) 13%,transparent)} /* empty states */ .hush{ display:flex;align-items:center;gap:10px;padding:14px 10px;color:var(--muted);font-size:12.5px; @@ -564,6 +594,9 @@ #drawer,#newPanel{width:100vw;border-left:none} .brow{flex-direction:column} .brow .send{width:100%} + .mcp-row{align-items:flex-start;flex-wrap:wrap} + .mcp-label{padding-top:2px} + .mcp-value{white-space:normal;overflow-wrap:anywhere} input,select,textarea{font-size:16px} /* prevents iOS zoom-on-focus */ } @media (max-width:480px){ @@ -594,6 +627,31 @@ - spend today
+
+ + +
' + @@ -1274,7 +1422,7 @@

Messages

$('#cRecent').textContent = arr.length || ''; const sig = arr.map(t => [ t?.id, t?.title, t?.status ?? t?.result?.status, t?.summary ?? t?.result?.summary, - t?.reason ?? t?.result?.reason, t?.prompt, + t?.reason ?? t?.result?.reason, t?.prompt, t?.source, fmtRel(t?.finishedAt ?? t?.result?.finishedAt) ].join('|')).join('~'); if (!changed('rec', sig)) return; @@ -1288,7 +1436,7 @@

Messages

return '
' + '' + esc(pt) + '' + '
' + - '
' + esc(t?.title || 'untitled') + '' + id + '
' + + '
' + esc(t?.title || 'untitled') + (t?.source === 'mcp' ? 'MCP' : '') + '' + id + '
' + '
' + esc(sum || '(no summary)') + '' + '
' + (reason ? '
reason ' + esc(reason) + '
' : '') + @@ -1554,6 +1702,7 @@

Messages

buildThemeMenu(); if (THEME_IDS.indexOf(document.documentElement.getAttribute('data-theme')) < 0) setTheme('tokyo-night'); markTheme(); + $('#connectorBtn').addEventListener('click', () => connectorOpen($('#connectorMenu').hidden)); $('#themeBtn').addEventListener('click', () => menuOpen($('#themeMenu').hidden)); $('#themeMenu').addEventListener('click', e => { const it = e.target.closest('.titem'); @@ -1564,6 +1713,7 @@

Messages

}); document.addEventListener('click', e => { if (!e.target.closest('.themer')) menuOpen(false); + if (!e.target.closest('.connector')) connectorOpen(false); }); $('#runningList').addEventListener('click', e => { @@ -1609,6 +1759,10 @@

Messages

const b = e.target.closest('.bubble.linked'); if (b) jumpToBlocked(b.getAttribute('data-task')); }); + $('#mcpToggle').addEventListener('click', toggleBridge); + $('#mcpUrlCopy').addEventListener('click', e => copyText(bridgeUrl(), e.currentTarget)); + $('#mcpReveal').addEventListener('click', toggleMcpToken); + $('#mcpTokenCopy').addEventListener('click', e => copyText(bridgeInfo.token, e.currentTarget)); $('#dclose').addEventListener('click', closeDrawer); $('#newBtn').addEventListener('click', openNew); $('#queueNew').addEventListener('click', openNew); @@ -1617,6 +1771,7 @@

Messages

document.addEventListener('keydown', e => { if (e.key !== 'Escape') return; if (!$('#themeMenu').hidden) { menuOpen(false); $('#themeBtn').focus({preventScroll: true}); } + else if (!$('#connectorMenu').hidden) { connectorOpen(false); $('#connectorBtn').focus({preventScroll: true}); } else if ($('#newPanel').classList.contains('open')) closeNew(); else closeDrawer(); }); @@ -1677,6 +1832,8 @@

Messages

if (document.visibilityState === 'visible') poll(); }); tick(); + renderBridge(); + loadBridge(); poll(); setInterval(poll, 2000); setInterval(tick, 1000); diff --git a/src/daemon.js b/src/daemon.js index 44818de..fa38a2b 100644 --- a/src/daemon.js +++ b/src/daemon.js @@ -27,6 +27,7 @@ let daemonInfo; let server; let ticker; let stopping = false; +let bridgeChild; function taskTime(task, field) { const value = Date.parse(task[field]); @@ -230,6 +231,136 @@ function stopWorker(child) { } } +function bridgePort() { + const port = Number(store.config.mcpBridge?.port); + + if (port !== 5758) { + return 5758; + } + + return port; +} + +function readBridgeHeartbeat() { + try { + return JSON.parse(fs.readFileSync(store.paths.bridge, 'utf8')); + } catch { + return null; + } +} + +function clearBridgeHeartbeat(pid) { + const heartbeat = readBridgeHeartbeat(); + + if (pid && heartbeat && heartbeat.pid !== pid) { + return; + } + + try { + fs.unlinkSync(store.paths.bridge); + } catch { + } +} + +function bridgeRunning() { + return store.isAlive(readBridgeHeartbeat()); +} + +function recoverBridgeHeartbeat() { + const heartbeat = readBridgeHeartbeat(); + + if (heartbeat && !store.isAlive(heartbeat)) { + clearBridgeHeartbeat(heartbeat.pid); + } +} + +function readBridgeToken() { + try { + const token = fs.readFileSync(store.paths.mcpToken, 'utf8').trim(); + return token || null; + } catch { + return null; + } +} + +function bridgeDetails() { + const port = bridgePort(); + const token = readBridgeToken(); + const localEndpoint = `http://127.0.0.1:${port}/mcp`; + + return { + running: bridgeRunning(), + port, + localEndpoint, + connectorUrl: token ? `${localEndpoint}?key=${encodeURIComponent(token)}` : localEndpoint, + token, + }; +} + +function startBridge() { + if (bridgeRunning()) { + return true; + } + + recoverBridgeHeartbeat(); + + if (bridgeChild && bridgeChild.exitCode === null && !bridgeChild.killed) { + return true; + } + + try { + const child = spawn(process.execPath, [path.join(store.paths.root, 'bridge.js')], { + cwd: store.paths.root, + stdio: 'ignore', + windowsHide: true, + }); + + bridgeChild = child; + child.unref(); + child.once('error', (error) => { + console.error(`Bridge failed to start: ${error.message}`); + if (bridgeChild === child) { + bridgeChild = undefined; + } + }); + child.once('exit', () => { + clearBridgeHeartbeat(child.pid); + if (bridgeChild === child) { + bridgeChild = undefined; + } + }); + return true; + } catch (error) { + console.error(`Bridge failed to start: ${error.message}`); + return false; + } +} + +function stopBridge() { + const heartbeat = readBridgeHeartbeat(); + + if (!store.isAlive(heartbeat)) { + recoverBridgeHeartbeat(); + return false; + } + + const child = bridgeChild && bridgeChild.pid === heartbeat.pid + ? bridgeChild + : { + pid: heartbeat.pid, + kill(signal) { + process.kill(this.pid, signal); + }, + }; + + terminateWorker(child); + clearBridgeHeartbeat(heartbeat.pid); + if (bridgeChild === child) { + bridgeChild = undefined; + } + return true; +} + function readWorkerOutput(outputPath, fallback) { try { const output = fs.readFileSync(outputPath, 'utf8'); @@ -1275,6 +1406,9 @@ function daemonState() { startedAt: daemonInfo.startedAt, ts: new Date().toISOString(), }, + bridge: { + running: bridgeRunning(), + }, stats: { pending: pendingTasks.length, running: runningTasks.length, @@ -1342,6 +1476,10 @@ function serveDashboard(res) { } } +function taskSource(body) { + return body && body.source === 'mcp' ? 'mcp' : 'api'; +} + async function dispatch(req, res) { let body; @@ -1371,7 +1509,7 @@ async function dispatch(req, res) { cwd: body.cwd, title: body.title, priority: body.priority, - source: 'api', + source: taskSource(body), }); recordEvent('queue', { id: task.id }); @@ -1513,7 +1651,7 @@ async function createLoop(req, res) { engine, model: store.config.model, title: `loop: ${project}`, - source: 'api', + source: taskSource(body), }); recordEvent('loop_queued', { id: loop.id }); sendJson(res, 201, { id: loop.id }); @@ -1646,6 +1784,11 @@ async function handleRequest(req, res) { return; } + if (req.method === 'GET' && requestPath === '/api/bridge') { + sendJson(res, 200, bridgeDetails()); + return; + } + if (req.method === 'GET' && requestPath.startsWith('/api/log/')) { serveLog(res, requestPath, requestUrl); return; @@ -1661,6 +1804,17 @@ async function handleRequest(req, res) { return; } + if (req.method === 'POST' && requestPath === '/api/bridge/start') { + sendJson(res, 200, { running: startBridge() || bridgeRunning() }); + return; + } + + if (req.method === 'POST' && requestPath === '/api/bridge/stop') { + stopBridge(); + sendJson(res, 200, { running: bridgeRunning() }); + return; + } + if (req.method === 'POST' && requestPath === '/api/cancel') { await cancelTask(req, res); return; @@ -1744,6 +1898,7 @@ function recoverRunningTasks() { function stop() { stopping = true; + stopBridge(); const workers = [...activeWorkers.values()]; for (const worker of workers) { @@ -1776,6 +1931,7 @@ function start() { return; } + recoverBridgeHeartbeat(); recoverRunningTasks(); server = http.createServer((req, res) => { diff --git a/src/store.js b/src/store.js index 4e96fb3..97928db 100644 --- a/src/store.js +++ b/src/store.js @@ -16,6 +16,8 @@ const paths = { logs: path.join(root, 'state', 'logs'), events: path.join(root, 'state', 'events.ndjson'), daemon: path.join(root, 'state', 'daemon.json'), + bridge: path.join(root, 'state', 'bridge.json'), + mcpToken: path.join(root, 'state', 'mcp-token'), }; const defaults = { @@ -23,6 +25,7 @@ const defaults = { maxConcurrent: 2, taskTimeoutMin: 45, defaultEngine: 'codex', + mcpBridge: { port: 5758 }, }; function loadConfig() { @@ -39,6 +42,9 @@ function loadConfig() { maxConcurrent: loaded.maxConcurrent ?? defaults.maxConcurrent, taskTimeoutMin: loaded.taskTimeoutMin ?? defaults.taskTimeoutMin, defaultEngine: loaded.defaultEngine ?? defaults.defaultEngine, + mcpBridge: { + port: loaded.mcpBridge?.port ?? defaults.mcpBridge.port, + }, }; if (loaded.model) { From 0efa32394a29efff19e0c6cfb4f1573c0924b50c Mon Sep 17 00:00:00 2001 From: aiedwardyi <41576951+aiedwardyi@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:51:33 +0900 Subject: [PATCH 2/4] fix: fail closed on empty bridge tokens and honor configured ports --- bridge.js | 59 +++++++++++++++++++++++++++++++++------------------ src/daemon.js | 7 +----- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/bridge.js b/bridge.js index 65c24f5..dec2bed 100644 --- a/bridge.js +++ b/bridge.js @@ -29,12 +29,7 @@ function loadConfig() { function bridgePort() { const port = Number(loadConfig().mcpBridge?.port); - - if (port !== 5758) { - return 5758; - } - - return port; + return Number.isInteger(port) && port >= 1024 && port <= 65535 ? port : 5758; } function dashboardPort() { @@ -50,27 +45,41 @@ function log(message) { } } -function readToken() { +function storedToken() { try { - const token = fs.readFileSync(tokenPath, 'utf8').trim(); - - if (token) { - return token; - } + return fs.readFileSync(tokenPath, 'utf8').trim(); } catch { + return ''; } +} - const token = crypto.randomBytes(32).toString('hex'); - fs.mkdirSync(statePath, { recursive: true }); +function readToken() { + while (true) { + const existing = storedToken(); - try { - fs.writeFileSync(tokenPath, `${token}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); - return token; - } catch (error) { - if (error.code === 'EEXIST') { - return fs.readFileSync(tokenPath, 'utf8').trim(); + if (existing) { + return existing; + } + + try { + fs.unlinkSync(tokenPath); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + const token = crypto.randomBytes(32).toString('hex'); + fs.mkdirSync(statePath, { recursive: true }); + + try { + fs.writeFileSync(tokenPath, `${token}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); + return token; + } catch (error) { + if (error.code !== 'EEXIST') { + throw error; + } } - throw error; } } @@ -152,6 +161,10 @@ function rpcError(id, code, message) { } function timingSafeEqual(left, right) { + if (!left || !right) { + return false; + } + const leftBuffer = Buffer.from(left); const rightBuffer = Buffer.from(right); @@ -163,6 +176,10 @@ function authorized(req, requestUrl) { const match = typeof header === 'string' ? /^Bearer\s+(.+)$/i.exec(header) : null; const candidate = match ? match[1].trim() : requestUrl.searchParams.get('key') || ''; + if (!candidate || !bridgeToken) { + return false; + } + return timingSafeEqual(candidate, bridgeToken); } diff --git a/src/daemon.js b/src/daemon.js index fa38a2b..337e763 100644 --- a/src/daemon.js +++ b/src/daemon.js @@ -233,12 +233,7 @@ function stopWorker(child) { function bridgePort() { const port = Number(store.config.mcpBridge?.port); - - if (port !== 5758) { - return 5758; - } - - return port; + return Number.isInteger(port) && port >= 1024 && port <= 65535 ? port : 5758; } function readBridgeHeartbeat() { From 9922ca1d428648f87e624297218188840347bd43 Mon Sep 17 00:00:00 2001 From: aiedwardyi <41576951+aiedwardyi@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:52:22 +0900 Subject: [PATCH 3/4] fix: copy authenticated connector URL and answer MCP pings --- bridge.js | 2 +- public/index.html | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/bridge.js b/bridge.js index dec2bed..2519d76 100644 --- a/bridge.js +++ b/bridge.js @@ -370,7 +370,7 @@ async function handleMessage(message) { capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'agentloop', version: '0.1.0' }, }; - } else if (message.method === 'notifications/initialized') { + } else if (message.method === 'notifications/initialized' || message.method === 'ping') { result = {}; } else if (message.method === 'tools/list') { result = { tools }; diff --git a/public/index.html b/public/index.html index 5d48625..32378c1 100644 --- a/public/index.html +++ b/public/index.html @@ -1137,9 +1137,12 @@

Messages

function bridgeEndpoint(){ return bridgeInfo.localEndpoint || 'http://127.0.0.1:5758/mcp'; } -function bridgeUrl(){ +function bridgeDisplayUrl(){ return mcpTokenVisible && bridgeInfo.connectorUrl ? bridgeInfo.connectorUrl : bridgeEndpoint(); } +function bridgeConnectorUrl(){ + return bridgeInfo.connectorUrl || bridgeEndpoint(); +} function renderBridge(state){ const running = typeof state?.running === 'boolean' ? state.running : bridgeInfo.running === true; bridgeInfo.running = running; @@ -1150,7 +1153,7 @@

Messages

status.className = 'pill ' + (running ? 'st-ok live' : 'st-mut'); status.innerHTML = '' + (running ? 'running' : 'stopped'); $('#mcpToggle').textContent = running ? 'Stop' : 'Start'; - $('#mcpUrl').textContent = bridgeUrl(); + $('#mcpUrl').textContent = bridgeDisplayUrl(); const token = mcpTokenVisible ? (bridgeInfo.token || 'Unavailable') : 'Hidden'; $('#mcpToken').textContent = token; $('#mcpReveal').textContent = mcpTokenVisible ? 'Hide' : 'Reveal'; @@ -1760,7 +1763,7 @@

Messages

if (b) jumpToBlocked(b.getAttribute('data-task')); }); $('#mcpToggle').addEventListener('click', toggleBridge); - $('#mcpUrlCopy').addEventListener('click', e => copyText(bridgeUrl(), e.currentTarget)); + $('#mcpUrlCopy').addEventListener('click', e => copyText(bridgeConnectorUrl(), e.currentTarget)); $('#mcpReveal').addEventListener('click', toggleMcpToken); $('#mcpTokenCopy').addEventListener('click', e => copyText(bridgeInfo.token, e.currentTarget)); $('#dclose').addEventListener('click', closeDrawer); From d41ac64877f03a3bdb045bd18c8cd76d19da4d85 Mon Sep 17 00:00:00 2001 From: aiedwardyi <41576951+aiedwardyi@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:52:57 +0900 Subject: [PATCH 4/4] fix: clarify connector reachability in dashboard helper text --- public/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/index.html b/public/index.html index 32378c1..cdbcb41 100644 --- a/public/index.html +++ b/public/index.html @@ -648,7 +648,7 @@
-

Add as an MCP connector (e.g. ChatGPT developer mode)

+

Local MCP endpoint. Works with MCP clients on this machine; hosted connectors (e.g. ChatGPT) need a public tunnel to reach it.