Skip to content

Commit 1b8889b

Browse files
Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 62d98d0 commit 1b8889b

File tree

6 files changed

+233
-72
lines changed

6 files changed

+233
-72
lines changed

src/js-host-api/examples/mcp-server/demo-copilot-cli.ps1

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -457,29 +457,6 @@ INSTRUCTIONS: You have an MCP tool called 'execute_javascript' from the 'hyperli
457457

458458
# Write the prompt to a temp file and pass via @file to avoid PS7's
459459
# native-command argument mangling with multi-line here-strings.
460-
# The copilot CLI reads -p @file the same as -p "string".
461-
# "Nobody puts Baby in a corner." — Dirty Dancing (1987)
462-
$promptFile = Join-Path ([System.IO.Path]::GetTempPath()) "hyperlight-prompt-$PID-$(Get-Random).txt"
463-
$fullPrompt | Set-Content -Path $promptFile -Encoding utf8NoBOM
464-
465-
# Build the command args list for copilot CLI.
466-
# We assemble it as an array so we can display it with -ShowCommand
467-
# before actually executing it.
468-
$copilotArgs = @(
469-
'-p', $fullPrompt,
470-
'-s',
471-
'--additional-mcp-config', "@$mcpTmp",
472-
'--allow-all-tools',
473-
'--deny-tool', 'shell',
474-
'--deny-tool', 'write',
475-
'--deny-tool', 'read',
476-
'--deny-tool', 'fetch',
477-
'--no-custom-instructions',
478-
'--no-ask-user',
479-
'--disable-builtin-mcps',
480-
'--model', $Model
481-
)
482-
483460
# Emit the command if -ShowCommand was requested.
484461
# "Show me the money!" — Jerry Maguire (1996... close enough to the 80s)
485462
if ($ShowCommand) {

src/js-host-api/examples/mcp-server/demo-copilot-cli.sh

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,24 @@ separator() {
8282
}
8383

8484
# Timing helpers — because "Time... is not on my side" — The Rolling Stones (1964, close enough)
85-
# Uses millisecond precision via date +%s%3N (GNU coreutils)
85+
# Uses millisecond precision when available; falls back to portable options on macOS/BSD.
8686
now_ms() {
87-
date +%s%3N
87+
# Prefer GNU date if available (system date or gdate), and verify output is numeric.
88+
if date +%s%3N 2>/dev/null | grep -Eq '^[0-9]+$'; then
89+
date +%s%3N
90+
elif command -v gdate >/dev/null 2>&1; then
91+
gdate +%s%3N
92+
elif command -v python3 >/dev/null 2>&1; then
93+
python3 - <<'EOF'
94+
import time, sys
95+
sys.stdout.write(str(int(time.time() * 1000)))
96+
EOF
97+
elif command -v node >/dev/null 2>&1; then
98+
node -e 'console.log(Date.now())'
99+
else
100+
# Portable fallback: seconds since epoch with millisecond field set to 000.
101+
date +%s000
102+
fi
88103
}
89104

90105
# Log elapsed time since a given start timestamp (ms)
@@ -144,8 +159,7 @@ check_prerequisites() {
144159
info "Verifying native addon loads correctly..."
145160
local lib_js="${SCRIPT_DIR}/../../lib.js"
146161
local smoke_err
147-
smoke_err="$(node --input-type=module -e "import('${lib_js}').then(() => console.log('OK')).catch(e => { console.error(e.message); process.exit(1); })" 2>&1)"
148-
if [[ $? -ne 0 ]]; then
162+
if ! smoke_err="$(node --input-type=module -e "import('${lib_js}').then(() => console.log('OK')).catch(e => { console.error(e.message); process.exit(1); })" 2>&1)"; then
149163
fail "Native addon failed to load — rebuild with 'just build'.\n${smoke_err}"
150164
fi
151165
ok "Native addon loads successfully"

src/js-host-api/examples/mcp-server/server.js

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -353,19 +353,59 @@ mcpServer.registerTool(
353353
}
354354
}
355355

356+
const safeStringifyResult = (value) => {
357+
const seen = new WeakSet();
358+
return JSON.stringify(
359+
value,
360+
(key, val) => {
361+
if (typeof val === 'bigint') {
362+
// Represent BigInt values as strings to avoid JSON.stringify throwing.
363+
return val.toString();
364+
}
365+
if (typeof val === 'object' && val !== null) {
366+
if (seen.has(val)) {
367+
// Replace circular references with a placeholder.
368+
return '[Circular]';
369+
}
370+
seen.add(val);
371+
}
372+
return val;
373+
},
374+
2
375+
);
376+
};
377+
356378
const startTime = Date.now();
357379
const { success, result, error } = await executeJavaScript(code);
358380
const elapsed = Date.now() - startTime;
359381

360382
if (success) {
361-
return {
362-
content: [
363-
{
364-
type: 'text',
365-
text: JSON.stringify(result, null, 2),
366-
},
367-
],
368-
};
383+
try {
384+
const serialized = safeStringifyResult(result);
385+
return {
386+
content: [
387+
{
388+
type: 'text',
389+
text: serialized,
390+
},
391+
],
392+
};
393+
} catch (serializeError) {
394+
return {
395+
content: [
396+
{
397+
type: 'text',
398+
text:
399+
`❌ Failed to serialize sandbox result: ` +
400+
(serializeError instanceof Error
401+
? serializeError.message
402+
: String(serializeError)) +
403+
`\n\n(elapsed: ${elapsed}ms)`,
404+
},
405+
],
406+
isError: true,
407+
};
408+
}
369409
} else {
370410
return {
371411
content: [

src/js-host-api/examples/mcp-server/tests/config.test.js

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,84 @@ function send(proc, message) {
2828
proc.stdin.write(JSON.stringify(message) + '\n');
2929
}
3030

31+
/**
32+
* Shared per-process stdout state to correctly handle NDJSON streams.
33+
* Ensures that multiple messages in a single chunk are not dropped and
34+
* that leftover buffered data is preserved across waitForResponse calls.
35+
*/
36+
const stdoutState = new WeakMap();
37+
38+
function getStdoutState(proc) {
39+
let state = stdoutState.get(proc);
40+
if (state) return state;
41+
42+
state = {
43+
buffer: '',
44+
pendingLines: [],
45+
waiters: [],
46+
listening: false,
47+
listener: null,
48+
};
49+
50+
const listener = (chunk) => {
51+
state.buffer += chunk.toString();
52+
53+
while (true) {
54+
const idx = state.buffer.indexOf('\n');
55+
if (idx === -1) break;
56+
57+
let line = state.buffer.slice(0, idx);
58+
state.buffer = state.buffer.slice(idx + 1);
59+
60+
// Normalize Windows line endings and skip empty lines.
61+
line = line.replace(/\r$/, '');
62+
if (line.length === 0) continue;
63+
64+
if (state.waiters.length > 0) {
65+
const { resolve, reject } = state.waiters.shift();
66+
try {
67+
resolve(JSON.parse(line));
68+
} catch (_err) {
69+
reject(new Error(`Invalid JSON from server: ${line}`));
70+
}
71+
} else {
72+
state.pendingLines.push(line);
73+
}
74+
}
75+
};
76+
77+
state.listener = listener;
78+
stdoutState.set(proc, state);
79+
return state;
80+
}
81+
3182
function waitForResponse(proc) {
83+
const state = getStdoutState(proc);
84+
3285
return new Promise((resolve, reject) => {
33-
let buffer = '';
34-
const onData = (chunk) => {
35-
buffer += chunk.toString();
36-
const idx = buffer.indexOf('\n');
37-
if (idx === -1) return;
38-
const line = buffer.slice(0, idx).replace(/\r$/, '');
39-
buffer = buffer.slice(idx + 1);
40-
proc.stdout.off('data', onData);
41-
if (line.length === 0) return;
86+
const deliverFromLine = (line) => {
4287
try {
4388
resolve(JSON.parse(line));
4489
} catch (_err) {
4590
reject(new Error(`Invalid JSON from server: ${line}`));
4691
}
4792
};
48-
proc.stdout.on('data', onData);
93+
94+
// If we already have a complete line buffered, use it immediately.
95+
if (state.pendingLines.length > 0) {
96+
const line = state.pendingLines.shift();
97+
deliverFromLine(line);
98+
return;
99+
}
100+
101+
// Otherwise, enqueue this waiter and let the shared listener fulfill it.
102+
state.waiters.push({ resolve, reject });
103+
104+
// Attach the shared listener once per process.
105+
if (!state.listening) {
106+
proc.stdout.on('data', state.listener);
107+
state.listening = true;
108+
}
49109
});
50110
}
51111

src/js-host-api/examples/mcp-server/tests/prompt-examples.test.js

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,24 +24,62 @@ function send(proc, message) {
2424
proc.stdin.write(JSON.stringify(message) + '\n');
2525
}
2626

27+
// Shared per-process line reader state: buffer, queued lines, and waiters.
28+
const procLineState = new WeakMap();
29+
30+
function ensureLineReader(proc) {
31+
let state = procLineState.get(proc);
32+
if (state) return state;
33+
34+
state = {
35+
buffer: '',
36+
lines: [],
37+
waiters: [],
38+
};
39+
40+
const onData = (chunk) => {
41+
state.buffer += chunk.toString();
42+
let idx;
43+
while ((idx = state.buffer.indexOf('\n')) !== -1) {
44+
let line = state.buffer.slice(0, idx).replace(/\r$/, '');
45+
state.buffer = state.buffer.slice(idx + 1);
46+
if (line.length === 0) {
47+
continue;
48+
}
49+
50+
if (state.waiters.length > 0) {
51+
const { resolve, reject } = state.waiters.shift();
52+
try {
53+
resolve(JSON.parse(line));
54+
} catch (_err) {
55+
reject(new Error(`Invalid JSON from server: ${line}`));
56+
}
57+
} else {
58+
state.lines.push(line);
59+
}
60+
}
61+
};
62+
63+
proc.stdout.on('data', onData);
64+
procLineState.set(proc, state);
65+
return state;
66+
}
67+
2768
function waitForResponse(proc) {
2869
return new Promise((resolve, reject) => {
29-
let buffer = '';
30-
const onData = (chunk) => {
31-
buffer += chunk.toString();
32-
const idx = buffer.indexOf('\n');
33-
if (idx === -1) return;
34-
const line = buffer.slice(0, idx).replace(/\r$/, '');
35-
buffer = buffer.slice(idx + 1);
36-
proc.stdout.off('data', onData);
37-
if (line.length === 0) return;
70+
const state = ensureLineReader(proc);
71+
72+
if (state.lines.length > 0) {
73+
const line = state.lines.shift();
3874
try {
3975
resolve(JSON.parse(line));
4076
} catch (_err) {
4177
reject(new Error(`Invalid JSON from server: ${line}`));
4278
}
43-
};
44-
proc.stdout.on('data', onData);
79+
return;
80+
}
81+
82+
state.waiters.push({ resolve, reject });
4583
});
4684
}
4785

src/js-host-api/examples/mcp-server/tests/timing.test.js

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,56 @@ function send(proc, message) {
2828
proc.stdin.write(JSON.stringify(message) + '\n');
2929
}
3030

31+
// Shared per-process NDJSON line reader state.
32+
const ndjsonState = new WeakMap();
33+
34+
function getNdjsonState(proc) {
35+
let state = ndjsonState.get(proc);
36+
if (state) return state;
37+
38+
state = {
39+
buffer: '',
40+
queue: [],
41+
waiting: [],
42+
};
43+
44+
const onData = (chunk) => {
45+
state.buffer += chunk.toString();
46+
let idx;
47+
// Extract all complete lines currently in the buffer.
48+
while ((idx = state.buffer.indexOf('\n')) !== -1) {
49+
let line = state.buffer.slice(0, idx).replace(/\r$/, '');
50+
state.buffer = state.buffer.slice(idx + 1);
51+
if (line.length === 0) continue;
52+
state.queue.push(line);
53+
}
54+
dispatchNdjson(state);
55+
};
56+
57+
proc.stdout.on('data', onData);
58+
ndjsonState.set(proc, state);
59+
return state;
60+
}
61+
62+
function dispatchNdjson(state) {
63+
// Pair up queued lines with waiting promises in FIFO order.
64+
while (state.queue.length > 0 && state.waiting.length > 0) {
65+
const line = state.queue.shift();
66+
const { resolve, reject } = state.waiting.shift();
67+
try {
68+
resolve(JSON.parse(line));
69+
} catch (_err) {
70+
reject(new Error(`Invalid JSON from server: ${line}`));
71+
}
72+
}
73+
}
74+
3175
function waitForResponse(proc) {
76+
const state = getNdjsonState(proc);
3277
return new Promise((resolve, reject) => {
33-
let buffer = '';
34-
const onData = (chunk) => {
35-
buffer += chunk.toString();
36-
const idx = buffer.indexOf('\n');
37-
if (idx === -1) return;
38-
const line = buffer.slice(0, idx).replace(/\r$/, '');
39-
buffer = buffer.slice(idx + 1);
40-
proc.stdout.off('data', onData);
41-
if (line.length === 0) return;
42-
try {
43-
resolve(JSON.parse(line));
44-
} catch (_err) {
45-
reject(new Error(`Invalid JSON from server: ${line}`));
46-
}
47-
};
48-
proc.stdout.on('data', onData);
78+
state.waiting.push({ resolve, reject });
79+
// In case lines were already queued before this call.
80+
dispatchNdjson(state);
4981
});
5082
}
5183

0 commit comments

Comments
 (0)