-
Notifications
You must be signed in to change notification settings - Fork 3
feat(daintree): fleet-substrate primitives — session, per-call workspace assertion, pane classification (#405) #406
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,248 @@ | ||
| #!/usr/bin/env node | ||
| 'use strict'; | ||
| // bin/daintree-fleet.cjs | ||
| // Daintree-as-fleet-substrate helper: session handshake, per-call workspace | ||
| // assertion, and pane classification for the TEAMLEAD/IMPLEMENTER pattern (#405). | ||
| // | ||
| // Every guard here exists because its absence caused a measured failure while | ||
| // running the pattern by hand. Each is cited at the point it is enforced. | ||
|
|
||
| const http = require('node:http'); | ||
| const https = require('node:https'); | ||
|
|
||
| /** | ||
| * Terminal panes render the input box ABOVE the status bar, so a shallow | ||
| * `includeOutput.lines` window cannot reach it. Measured: at lines=5 the box is | ||
| * structurally invisible and every read returns "empty"; at lines=14 it appears. | ||
| * Six queued instructions went unseen for an hour behind a lines=5 window. | ||
| */ | ||
| const BOX_TAIL_LINES = 14; | ||
|
|
||
| /** A pane idle beyond this with an empty scrollback has no agent behind it. */ | ||
| const DEAD_PANE_MIN = 120; | ||
|
|
||
| /** Prompt marker the agent pane renders for its input line. */ | ||
| const PROMPT_MARK = '❯'; | ||
|
|
||
| /** | ||
| * Separates instrument validity from domain value, so a failed read can never | ||
| * be mistaken for a legitimate value (e.g. "no pending input"). | ||
| * @param {boolean} valid | ||
| * @param {*} value | ||
| * @param {string|null} error | ||
| * @returns {{valid: boolean, value: *, error: string|null}} | ||
| */ | ||
| function reading(valid, value = null, error = null) { | ||
| return { valid, value, error }; | ||
| } | ||
|
|
||
| /** | ||
| * Recover unsubmitted text from a pane's rendered scrollback. | ||
| * | ||
| * This is a workaround, not a design: pending input should be a first-class | ||
| * field. Callers that pass a short tail will silently get null. | ||
| * @param {string|string[]} recentOutput | ||
| * @returns {string|null} pending text, or null when the box is empty | ||
| */ | ||
| function extractPendingInput(recentOutput) { | ||
| const text = Array.isArray(recentOutput) ? recentOutput.join('\n') : (recentOutput || ''); | ||
| const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.startsWith(PROMPT_MARK)); | ||
| if (lines.length === 0) return null; | ||
| const pending = lines[lines.length - 1].slice(PROMPT_MARK.length).trim(); | ||
| return pending.length > 0 ? pending : null; | ||
| } | ||
|
|
||
| /** | ||
| * Assert the resolved workspace on EVERY response. | ||
| * | ||
| * The MCP session resolves against the host's ACTIVE workspace and ignores a | ||
| * workspaceId argument. Measured: it flipped mid-session between two calls in | ||
| * one cycle, to an unrelated project that had its own identically-titled panes. | ||
| * A connect-time assertion passes and is then silently wrong. | ||
| * @param {object} result - the JSON-RPC `result` object | ||
| * @param {string} expectedWorkspaceId | ||
| * @returns {{valid: boolean, value: *, error: string|null}} | ||
| */ | ||
| function assertWorkspace(result, expectedWorkspaceId) { | ||
| const meta = (result && result._meta) || {}; | ||
| const ws = meta['org.daintree/resolved-workspace'] || {}; | ||
| if (!expectedWorkspaceId) return reading(true, ws); | ||
| if (ws.workspaceId !== expectedWorkspaceId) { | ||
| return reading(false, ws, | ||
| `workspace flip: resolved ${ws.workspacePath || '?'} (${String(ws.workspaceId || '').slice(0, 12)}), expected ${expectedWorkspaceId.slice(0, 12)}`); | ||
| } | ||
| return reading(true, ws); | ||
| } | ||
|
|
||
| /** | ||
| * Classify a pane into the four states an orchestrator must distinguish. | ||
| * | ||
| * `agentState` alone collapses IDLE and UNDELIVERED into "waiting". Acting on | ||
| * that conflation is destructive: retasking a pane with pending input overwrites | ||
| * the instruction it was waiting on. | ||
| * @param {object} pane - merged terminal.list + terminal.getStatus entry | ||
| * @param {number} nowMs | ||
| * @param {{idleMinutes?: number}} [opts] | ||
| * @returns {{kind: string, title: string, terminalId: string, idleMin: number, pendingInput: string|null, action: string}} | ||
| */ | ||
| function classifyPane(pane, nowMs, opts = {}) { | ||
| const idleThreshold = opts.idleMinutes != null ? opts.idleMinutes : 10; | ||
| const idleMin = (nowMs - (pane.lastTransitionAt || nowMs)) / 60000; | ||
| const raw = Array.isArray(pane.recentOutput) ? pane.recentOutput.join('\n') : (pane.recentOutput || ''); | ||
| const pending = extractPendingInput(raw); | ||
| const scrollbackEmpty = raw.trim().length <= 8; | ||
|
|
||
| let kind; | ||
| let action; | ||
| if (scrollbackEmpty || idleMin > DEAD_PANE_MIN) { | ||
| // lastTransitionAt advances on a dead pane too, so it cannot establish | ||
| // liveness on its own. Only an empty scrollback separates the two. | ||
| kind = 'DEAD'; | ||
| action = 'no agent behind this pane; do not dispatch here'; | ||
| } else if (pending) { | ||
| kind = 'UNDELIVERED'; | ||
| action = 'deliver the pending instruction; do NOT retask (send replaces the box)'; | ||
| } else if (pane.agentState === 'waiting' && idleMin >= idleThreshold) { | ||
| kind = 'IDLE'; | ||
| action = 'genuinely free; retask or confirm done'; | ||
| } else { | ||
| kind = 'WORKING'; | ||
| action = 'none'; | ||
| } | ||
| return { | ||
| kind, | ||
| title: pane.title || '(untitled)', | ||
| terminalId: pane.terminalId || pane.id, | ||
| idleMin: Math.round(idleMin * 10) / 10, | ||
| pendingInput: pending, | ||
| action, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Decide whether a submission may proceed. | ||
| * | ||
| * Two measured footguns: `sendCommand` REPLACES pending input (silently losing | ||
| * it), and a pane whose agent has exited accepts the text as a shell command | ||
| * while still reporting success. | ||
| * @param {object} classified - output of classifyPane | ||
| * @param {{force?: boolean}} [opts] | ||
| * @returns {{allowed: boolean, reason: string|null, displaced: string|null}} | ||
| */ | ||
| function sendGuard(classified, opts = {}) { | ||
| if (classified.kind === 'DEAD') { | ||
| return { allowed: false, reason: 'pane has no live agent; text would run as a shell command', displaced: null }; | ||
| } | ||
| if (classified.pendingInput && !opts.force) { | ||
| return { | ||
| allowed: false, | ||
| reason: 'pane holds unsubmitted input; sending would discard it (pass force to override)', | ||
| displaced: classified.pendingInput, | ||
| }; | ||
| } | ||
| return { allowed: true, reason: null, displaced: classified.pendingInput || null }; | ||
| } | ||
|
|
||
| /** | ||
| * An enqueue acknowledgement is not delivery. Measured: `{"sent": true}` was | ||
| * returned for a dead pane, for a flipped workspace, and for text that landed | ||
| * in the box unsubmitted. Callers must verify an off-pane effect. | ||
| * @param {object} sendResult | ||
| * @returns {{acknowledged: boolean, delivered: null, note: string}} | ||
| */ | ||
| function interpretSendResult(sendResult) { | ||
| const ack = Boolean(sendResult && sendResult.sent); | ||
| return { | ||
| acknowledged: ack, | ||
| delivered: null, | ||
| note: 'acknowledgement only — confirm an off-pane effect (branch pushed, file changed, state advanced)', | ||
| }; | ||
| } | ||
|
|
||
| // ── transport ─────────────────────────────────────────────────────────────── | ||
|
|
||
| function postJson(url, headers, body) { | ||
| return new Promise((resolve, reject) => { | ||
| const u = new URL(url); | ||
| const lib = u.protocol === 'https:' ? https : http; | ||
| const req = lib.request( | ||
| { method: 'POST', hostname: u.hostname, port: u.port, path: u.pathname, | ||
| headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...headers } }, | ||
| (res) => { | ||
| let data = ''; | ||
| res.on('data', (c) => { data += c; }); | ||
| res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data })); | ||
| }); | ||
| req.on('error', reject); | ||
| req.end(JSON.stringify(body)); | ||
|
Comment on lines
+171
to
+177
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- file outline ---'
ast-grep outline bin/daintree-fleet.cjs --lang javascript || true
printf '%s\n' '--- target source ---'
cat -n bin/daintree-fleet.cjs | sed -n '130,195p'
printf '%s\n' '--- transport-related symbols ---'
rg -n -C 3 'postJson|setTimeout|timeout|abort|aborted|req\.on|res\.on|https?\.request' bin/daintree-fleet.cjs
printf '%s\n' '--- package scripts ---'
if [ -f package.json ]; then
node - <<'JS'
const fs = require('fs');
const p = JSON.parse(fs.readFileSync('package.json', 'utf8'));
console.log(JSON.stringify(p.scripts || {}, null, 2));
JS
fiRepository: nForma-AI/nForma Length of output: 14628 🏁 Script executed: #!/bin/bash
set -eu
node - <<'JS'
const http = require('node:http');
function postJson(url, headers, body) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = http.request(
{
method: 'POST',
hostname: u.hostname,
port: u.port,
path: u.pathname,
headers: { 'Content-Type': 'application/json', ...headers },
},
(res) => {
let data = '';
res.on('data', (c) => { data += c; });
res.on('end', () => resolve({ status: res.statusCode, body: data }));
},
);
req.on('error', reject);
req.end(JSON.stringify(body));
});
}
const server = http.createServer((req, res) => {
if (req.url === '/hang') {
res.writeHead(200);
res.write('partial');
return;
}
if (req.url === '/abort') {
res.writeHead(200);
res.write('partial');
setImmediate(() => res.destroy());
return;
}
res.end('unexpected');
});
server.listen(0, '127.0.0.1', async () => {
const base = `http://127.0.0.1:${server.address().port}`;
let hangSettled = false;
const hang = postJson(`${base}/hang`, {}, {});
hang.then(() => { hangSettled = true; }, () => { hangSettled = true; });
await new Promise((r) => setTimeout(r, 100));
console.log(JSON.stringify({
case: 'never-ending response',
settledAfter100ms: hangSettled,
}));
const events = [];
await new Promise((resolve, reject) => {
const u = new URL(`${base}/abort`);
const req = http.request(
{ method: 'POST', hostname: u.hostname, port: u.port, path: u.pathname },
(res) => {
for (const event of ['data', 'end', 'error', 'aborted', 'close']) {
res.on(event, (...args) => {
events.push(event);
if (event === 'error') reject(args[0]);
});
}
},
);
req.on('error', (error) => events.push(`request-error:${error.code || error.message}`));
req.end('{}');
setTimeout(resolve, 150);
});
console.log(JSON.stringify({ case: 'aborted response', events }));
server.close();
});
JSRepository: nForma-AI/nForma Length of output: 280 🌐 Web query:
💡 Result: In the Model Context Protocol (MCP) specification (including the 2025-06-18 version), timeout management is a critical requirement for maintaining connection stability and preventing system-level resource exhaustion [1][2]. Key aspects of the specification regarding timeouts, hung connections, and resource exhaustion include: Requirement to Implement Timeouts: The specification states that implementations SHOULD establish timeouts for all sent requests to prevent hung connections and resource exhaustion [1][2]. When a request fails to receive a success or error response within the designated period, the sender SHOULD issue a cancellation notification and cease waiting for the response [1][2]. Configuration: To provide flexibility, the specification advises that SDKs and middleware SHOULD allow timeouts to be configured on a per-request basis [1][2]. Handling Progress Notifications: Implementations MAY reset the timeout clock when receiving a progress notification for a request, as this indicates active processing [1][2]. However, the specification emphasizes that implementations SHOULD always enforce a maximum timeout, even when receiving progress notifications, to mitigate the impact of misbehaving clients or servers [1][2]. Evolution and Context: While the core specification provides these requirements, practical implementation has seen discussion regarding consistency across different language SDKs (such as TypeScript vs. Python) and the need for more coordinated timeout strategies to prevent exploitation of long-timeout scenarios [3][4]. For example, developers have noted that mismatched defaults or excessive timeouts can lead to vulnerabilities similar to resource exhaustion attacks [3]. Consequently, ongoing development efforts have focused on better timeout coordination and ensuring robust enforcement across all MCP implementations [3][4]. Citations:
🏁 Script executed: #!/bin/bash
set -eu
node - <<'JS'
const http = require('node:http');
function rawPost(port, path, onResponse) {
return new Promise((resolve, reject) => {
const req = http.request(
{ method: 'POST', hostname: '127.0.0.1', port, path },
(res) => onResponse(res, resolve, reject),
);
req.on('error', (error) => reject(error));
req.end('{}');
});
}
const server = http.createServer((req, res) => {
console.log(`server:${req.url}`);
if (req.url === '/hang') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.flushHeaders();
res.write('partial');
return;
}
if (req.url === '/abort') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.flushHeaders();
res.write('partial');
setTimeout(() => res.destroy(), 20);
return;
}
res.end('unexpected');
});
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
console.log(`listening:${port}`);
let hangSettled = false;
const hanging = rawPost(port, '/hang', (res, resolve, reject) => {
res.on('data', () => {});
res.on('end', () => resolve('end'));
res.on('error', (error) => reject(error));
});
hanging.then(
() => { hangSettled = true; },
() => { hangSettled = true; },
);
setTimeout(() => {
console.log(JSON.stringify({
case: 'never-ending response',
settledAfter200ms: hangSettled,
}));
const events = [];
rawPost(port, '/abort', (res, resolve, reject) => {
for (const event of ['data', 'end', 'error', 'aborted', 'close']) {
res.on(event, (...args) => {
events.push(event + (event === 'error' ? `:${args[0].code || args[0].message}` : ''));
if (event === 'error') reject(args[0]);
if (event === 'close') resolve();
});
}
}).then(
() => console.log(JSON.stringify({ case: 'aborted response', outcome: 'resolved', events })),
(error) => console.log(JSON.stringify({ case: 'aborted response', outcome: 'rejected', error: error.code || error.message, events })),
).finally(() => {
setTimeout(() => {
server.close();
process.exit(0);
}, 20);
});
}, 200);
});
JSRepository: nForma-AI/nForma Length of output: 312 🌐 Web query:
💡 Result: In Node.js, managing request lifecycles and understanding event behavior is crucial for stable applications. ### IncomingMessage Events and Deprecation The 'aborted' event on http.IncomingMessage is deprecated [1][2]. It was historically used to signal when a request was prematurely terminated by the client, but it has caused confusion due to inconsistent behavior across different Node.js versions and its overlap with the 'close' event [1][3]. Modern best practices recommend: - Listening for the 'close' event instead of 'aborted' [1]. - Checking the state of the request (e.g., using request.readableEnded) within the 'close' event handler to determine if the stream ended normally or was interrupted [1]. - Note that an 'end' event may still follow an 'aborted' or 'close' event depending on the specific circumstances of the disconnection [4]. ### ClientRequest: Timeouts and Destruction ClientRequest objects represent outgoing HTTP requests. Because 'timeout' events only notify that a socket has been idle—they do not automatically terminate the request—you must manually handle the cleanup [5][6]. 1. Timeout Handling: - The 'timeout' event is emitted when the underlying socket remains inactive for the specified period [5][7]. - After receiving a timeout, you must explicitly call request.destroy to terminate the request and free resources [5][6]. 2. Request Destruction: - request.destroy([error]) is the definitive way to stop a request [5][6]. - When called, it optionally emits an 'error' event and always emits a 'close' event [5][6]. - This method ensures that remaining response data is discarded and the underlying socket is either destroyed or returned to the Agent pool [5][6]. ### Summary of Best Practices Always attach an 'error' listener to your request objects to prevent unhandled exceptions, especially when dealing with network-level interruptions. If you are building robust systems, prefer the 'close' event over 'aborted' to detect premature connection termination, and always explicitly destroy requests that exceed their timeout threshold [1][5]. Citations:
Bound and settle every transport request. Add a configurable deadline that destroys the request and rejects on timeout. Reject on response 🤖 Prompt for AI Agents |
||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Open a session. The handshake (initialize -> capture Mcp-Session-Id -> | ||
| * notifications/initialized) is boilerplate every caller would otherwise | ||
| * reimplement; getting it wrong yields "Server not initialized". | ||
| * @param {{url: string, token: string}} cfg | ||
| * @returns {Promise<{valid: boolean, value: *, error: string|null}>} | ||
| */ | ||
| async function openSession(cfg) { | ||
| const auth = { Authorization: `Bearer ${cfg.token}` }; | ||
| let res; | ||
| try { | ||
| res = await postJson(cfg.url, auth, { | ||
| jsonrpc: '2.0', id: 0, method: 'initialize', | ||
| params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'nforma-fleet', version: '1' } }, | ||
| }); | ||
| } catch (e) { | ||
| return reading(false, null, `transport: ${e.message}`); | ||
| } | ||
| const sid = res.headers['mcp-session-id']; | ||
| if (!sid) return reading(false, null, `no Mcp-Session-Id (status ${res.status})`); | ||
| await postJson(cfg.url, { ...auth, 'Mcp-Session-Id': sid }, { jsonrpc: '2.0', method: 'notifications/initialized' }); | ||
| return reading(true, { sessionId: sid, headers: { ...auth, 'Mcp-Session-Id': sid } }); | ||
|
Comment on lines
+192
to
+202
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- file outline ---'
ast-grep outline bin/daintree-fleet.cjs --match '$_' --view compact 2>/dev/null | head -200 || true
printf '%s\n' '--- target source ---'
cat -n bin/daintree-fleet.cjs | sed -n '1,260p'
printf '%s\n' '--- relevant symbols and protocol headers ---'
rg -n -C 4 'postJson|openSession|callTool|Mcp-Session-Id|MCP-Protocol-Version|notifications/initialized|initialize|Abort|timeout|query' bin/daintree-fleet.cjs
printf '%s\n' '--- repository references ---'
rg -n -C 3 'MCP-Protocol-Version|notifications/initialized|mcp-session-id|openSession|postJson' . --glob '!node_modules' --glob '!dist' --glob '!build' | head -300Repository: nForma-AI/nForma Length of output: 19165 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- unified server protocol handling ---'
ast-grep outline bin/unified-mcp-server.mjs --match '$_' --view compact 2>/dev/null | head -250 || true
rg -n -C 8 'protocolVersion|MCP-Protocol-Version|mcp-session-id|notifications/initialized|initialize|statusCode|writeHead|res\.end' bin/unified-mcp-server.mjs | head -400
printf '%s\n' '--- package scripts and tests ---'
cat -n package.json | sed -n '1,220p'
rg -n -C 5 'daintree-fleet|openSession|callTool|notifications/initialized|MCP-Protocol-Version' test bin README.md .github --glob '!node_modules' 2>/dev/null | head -400
printf '%s\n' '--- URL and transport edge-case references ---'
rg -n -C 3 'new URL|http\.request|https\.request|setTimeout|AbortSignal|searchParams|pathname' bin test --glob '*.cjs' --glob '*.mjs' | head -300Repository: nForma-AI/nForma Length of output: 40823 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- complete server transport context ---'
cat -n bin/unified-mcp-server.mjs | sed -n '860,980p'
printf '%s\n' '--- exact fleet tests ---'
cat -n bin/daintree-fleet.test.cjs | sed -n '1,260p'
printf '%s\n' '--- standalone Node transport probe ---'
node - <<'JS'
'use strict';
const http = require('node:http');
function postJson(url, headers, body) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const lib = u.protocol === 'https:' ? require('node:https') : http;
const req = lib.request(
{ method: 'POST', hostname: u.hostname, port: u.port, path: u.pathname,
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...headers } },
(res) => {
let data = '';
res.on('data', (c) => { data += c; });
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }));
});
req.on('error', reject);
req.end(JSON.stringify(body));
});
}
const requests = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', c => { body += c; });
req.on('end', () => {
requests.push({ path: req.url, headers: req.headers, body: JSON.parse(body) });
if (requests.length === 1) {
res.writeHead(200, {
'MCP-Session-Id': 'sid-1',
'MCP-Protocol-Version': '2024-11-05',
'Content-Type': 'application/json',
});
res.end(JSON.stringify({ jsonrpc: '2.0', id: 0, result: { protocolVersion: '2024-11-05' } }));
} else {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end('{}');
}
});
});
server.listen(0, '127.0.0.1', async () => {
const url = `http://127.0.0.1:${server.address().port}/mcp?tenant=one`;
const initialize = await postJson(url, { Authorization: 'Bearer test' }, {
jsonrpc: '2.0', id: 0, method: 'initialize',
});
let notificationError = null;
try {
await postJson(url, { Authorization: 'Bearer test', 'Mcp-Session-Id': initialize.headers['mcp-session-id'] }, {
jsonrpc: '2.0', method: 'notifications/initialized',
});
} catch (e) {
notificationError = e.message;
}
console.log(JSON.stringify({
requestPaths: requests.map(r => r.path),
initializeStatus: initialize.status,
negotiatedResponseHeader: initialize.headers['mcp-protocol-version'],
notificationStatus: notificationError || 'resolved despite HTTP 500',
notificationHeaders: requests[1].headers,
}, null, 2));
server.close();
});
JSRepository: nForma-AI/nForma Length of output: 12393 🌐 Web query:
💡 Result: In the Model Context Protocol (MCP) specification version 2025-06-18, the Streamable HTTP transport defines how JSON-RPC messages are handled and how the client and server establish a session [1][2]. Key technical details regarding your query include: 1. Initialized Notification: After the initial handshake (the initialize request/response), the client must send a notifications/initialized notification to the server to signal that it is ready to begin operations [3][4]. This notification is a standard JSON-RPC message [3][4]. 2. HTTP 202 Accepted: When using the Streamable HTTP transport, if the server accepts a JSON-RPC request or notification (such as notifications/initialized), it must respond with an HTTP status code 202 Accepted [5][2]. This response typically contains no body [2]. 3. MCP-Protocol-Version Header: For all HTTP requests subsequent to the initial handshake, the client is required to include the MCP-Protocol-Version header [1][2]. This header allows the server to identify and respond according to the negotiated protocol version (e.g., 2025-06-18) [1][2]. If a server receives a request with an invalid or missing version header, it is required to respond with an HTTP 400 Bad Request error [1][2]. These requirements ensure that the protocol remains stateful and correctly versioned across HTTP interactions [1][5]. Citations:
Complete the MCP handshake before returning a session.
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** | ||
| * Invoke a tool, asserting workspace binding on the response. | ||
| * @param {{url: string, workspaceId?: string}} cfg | ||
| * @param {object} session - value from openSession | ||
| * @param {string} name | ||
| * @param {object} args | ||
| */ | ||
| async function callTool(cfg, session, name, args) { | ||
| let res; | ||
| try { | ||
| res = await postJson(cfg.url, session.headers, { | ||
| jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args || {} }, | ||
| }); | ||
| } catch (e) { | ||
| return reading(false, null, `transport: ${e.message}`); | ||
| } | ||
| let parsed; | ||
| try { | ||
| const line = res.body.replace(/^data: /gm, '').trim().split('\n').filter(Boolean).pop(); | ||
| parsed = JSON.parse(line); | ||
| } catch (e) { | ||
| return reading(false, null, `unparseable response: ${e.message}`); | ||
| } | ||
| if (parsed.error) return reading(false, null, `rpc error: ${JSON.stringify(parsed.error).slice(0, 200)}`); | ||
| const ws = assertWorkspace(parsed.result, cfg.workspaceId); | ||
| if (!ws.valid) return reading(false, null, ws.error); | ||
| const content = ((parsed.result || {}).content || [{}])[0] || {}; | ||
| if (parsed.result && parsed.result.isError) return reading(false, null, String(content.text || '').slice(0, 200)); | ||
| return reading(true, content.text); | ||
| } | ||
|
|
||
| module.exports = { | ||
| BOX_TAIL_LINES, | ||
| DEAD_PANE_MIN, | ||
| PROMPT_MARK, | ||
| reading, | ||
| extractPendingInput, | ||
| assertWorkspace, | ||
| classifyPane, | ||
| sendGuard, | ||
| interpretSendResult, | ||
| openSession, | ||
| callTool, | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Preserve endpoint query parameters.
Line 169 sends only
u.pathname. Ifcfg.urlhas required query parameters, the request targets a different endpoint. Appendu.searchtopath.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents