@pugi/cli 0.1.0-beta.4 → 0.1.0-beta.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-mascot.ansi +15 -25
- package/bin/run.js +33 -1
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- package/dist/commands/smoke.js +133 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/artifact-chain/dispatcher.js +148 -0
- package/dist/core/artifact-chain/exporter.js +164 -0
- package/dist/core/artifact-chain/state.js +243 -0
- package/dist/core/artifact-chain/steps.js +169 -0
- package/dist/core/auth/ensure-authenticated.js +129 -0
- package/dist/core/auth/env-provider.js +238 -0
- package/dist/core/auto-update/channels.js +122 -0
- package/dist/core/auto-update/checker.js +241 -0
- package/dist/core/auto-update/state.js +235 -0
- package/dist/core/bare-mode/index.js +107 -0
- package/dist/core/bash-classifier.js +108 -1
- package/dist/core/checkpoint/resumer.js +149 -0
- package/dist/core/checkpoint/rewinder.js +291 -0
- package/dist/core/codegraph/decision-store.js +248 -0
- package/dist/core/codegraph/detect-repo.js +459 -0
- package/dist/core/codegraph/install.js +134 -0
- package/dist/core/codegraph/offer-hook.js +220 -0
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +208 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/consensus/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/bare-mode.js +42 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/pugi-md.js +89 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/dispatch/cache-cleanup.js +197 -0
- package/dist/core/dispatch/cache-handoff.js +295 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +322 -0
- package/dist/core/engine/anvil-client.js +115 -5
- package/dist/core/engine/budgets.js +98 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +860 -211
- package/dist/core/engine/prompts.js +88 -2
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +992 -36
- package/dist/core/feedback/queue.js +177 -0
- package/dist/core/feedback/submitter.js +145 -0
- package/dist/core/file-cache.js +113 -1
- package/dist/core/hooks/events.js +44 -0
- package/dist/core/hooks/index.js +15 -0
- package/dist/core/hooks/registry.js +213 -0
- package/dist/core/hooks/runner.js +236 -0
- package/dist/core/hooks/v2/event-emitter.js +115 -0
- package/dist/core/hooks/v2/executor.js +282 -0
- package/dist/core/hooks/v2/index.js +25 -0
- package/dist/core/hooks/v2/lifecycle.js +104 -0
- package/dist/core/hooks/v2/loader.js +216 -0
- package/dist/core/hooks/v2/matcher.js +125 -0
- package/dist/core/hooks/v2/trust.js +143 -0
- package/dist/core/hooks/v2/types.js +86 -0
- package/dist/core/lsp/cache.js +105 -0
- package/dist/core/lsp/client.js +776 -0
- package/dist/core/lsp/language-detect.js +66 -0
- package/dist/core/lsp/post-edit-diagnostics.js +171 -0
- package/dist/core/mcp/client.js +75 -6
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/orchestrator-tools.js +662 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/memory/dual-write.js +416 -0
- package/dist/core/memory/phase1-kinds.js +20 -0
- package/dist/core/memory-sync/queue.js +158 -0
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/onboarding/marker.js +111 -0
- package/dist/core/onboarding/telemetry-state.js +108 -0
- package/dist/core/output-style/presets.js +176 -0
- package/dist/core/output-style/state.js +185 -0
- package/dist/core/permissions/auto-classifier.js +124 -0
- package/dist/core/permissions/circuit-breaker.js +83 -0
- package/dist/core/permissions/gate.js +278 -0
- package/dist/core/permissions/index.js +20 -0
- package/dist/core/permissions/mode.js +174 -0
- package/dist/core/permissions/state.js +241 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/prd-check/parser.js +215 -0
- package/dist/core/prd-check/reporter.js +127 -0
- package/dist/core/prd-check/session-review.js +557 -0
- package/dist/core/prd-check/verifiers.js +223 -0
- package/dist/core/pugi-md/context-injector.js +76 -0
- package/dist/core/pugi-md/walk-up.js +207 -0
- package/dist/core/release-notes/parser.js +241 -0
- package/dist/core/release-notes/state.js +116 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/session.js +1899 -38
- package/dist/core/repl/slash-commands.js +406 -21
- package/dist/core/repl/store/session-store.js +31 -2
- package/dist/core/repl/workspace-context.js +22 -0
- package/dist/core/repo-map/build.js +125 -0
- package/dist/core/repo-map/cache.js +185 -0
- package/dist/core/repo-map/extractor.js +254 -0
- package/dist/core/repo-map/formatter.js +145 -0
- package/dist/core/repo-map/scanner.js +211 -0
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/session.js +92 -0
- package/dist/core/settings.js +80 -0
- package/dist/core/share/formatter.js +271 -0
- package/dist/core/share/redactor.js +221 -0
- package/dist/core/share/uploader.js +267 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/smoke/headless-driver.js +174 -0
- package/dist/core/smoke/orchestrator.js +194 -0
- package/dist/core/smoke/runner.js +238 -0
- package/dist/core/smoke/scenario-parser.js +316 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/telemetry/emitter.js +229 -0
- package/dist/core/telemetry/queue.js +251 -0
- package/dist/core/theme/context.js +91 -0
- package/dist/core/theme/presets.js +228 -0
- package/dist/core/theme/state.js +181 -0
- package/dist/core/todos/invariant.js +10 -0
- package/dist/core/todos/state.js +177 -0
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/core/vim/keymap.js +288 -0
- package/dist/core/vim/state.js +92 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +3073 -321
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/chain.js +489 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/compact.js +297 -0
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +242 -11
- package/dist/runtime/commands/dispatch.js +126 -0
- package/dist/runtime/commands/doctor.js +390 -0
- package/dist/runtime/commands/feedback.js +184 -0
- package/dist/runtime/commands/hooks.js +184 -0
- package/dist/runtime/commands/lsp.js +368 -0
- package/dist/runtime/commands/mcp.js +879 -0
- package/dist/runtime/commands/memory.js +508 -0
- package/dist/runtime/commands/model.js +237 -0
- package/dist/runtime/commands/onboarding.js +275 -0
- package/dist/runtime/commands/patch.js +128 -0
- package/dist/runtime/commands/permissions.js +112 -0
- package/dist/runtime/commands/plan.js +143 -0
- package/dist/runtime/commands/prd-check.js +285 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/release-notes.js +229 -0
- package/dist/runtime/commands/repo-map.js +95 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/resume.js +118 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/commands/rewind.js +333 -0
- package/dist/runtime/commands/sessions.js +163 -0
- package/dist/runtime/commands/share.js +316 -0
- package/dist/runtime/commands/status.js +186 -0
- package/dist/runtime/commands/stickers.js +82 -0
- package/dist/runtime/commands/style.js +194 -0
- package/dist/runtime/commands/theme.js +196 -0
- package/dist/runtime/commands/undo.js +32 -0
- package/dist/runtime/commands/update.js +289 -0
- package/dist/runtime/commands/vim.js +140 -0
- package/dist/runtime/commands/worktree.js +177 -0
- package/dist/runtime/headless-repl.js +195 -0
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +229 -0
- package/dist/tools/apply-patch.js +556 -0
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/file-tools.js +85 -14
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +46 -0
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/todo-write.js +184 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +81 -0
- package/dist/tui/conversation-pane.js +82 -8
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/doctor-table.js +46 -0
- package/dist/tui/feedback-prompt.js +156 -0
- package/dist/tui/input-box.js +69 -2
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/onboarding-wizard.js +240 -0
- package/dist/tui/permissions-picker.js +86 -0
- package/dist/tui/render.js +35 -0
- package/dist/tui/repl-render.js +303 -13
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +72 -14
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/stickers-art.js +136 -0
- package/dist/tui/style-table.js +28 -0
- package/dist/tui/theme-table.js +29 -0
- package/dist/tui/tool-stream-pane.js +52 -3
- package/dist/tui/update-banner.js +20 -2
- package/dist/tui/vim-input.js +267 -0
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +12 -6
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +11 -0
- package/test/scenarios/identity.scenario.txt +11 -0
- package/test/scenarios/persona-handoff.scenario.txt +11 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { MCP_ERROR_CODES, } from './server.js';
|
|
5
|
+
const DEFAULT_LOCALHOST_ORIGINS = Object.freeze([
|
|
6
|
+
'http://localhost',
|
|
7
|
+
'http://127.0.0.1',
|
|
8
|
+
'http://[::1]',
|
|
9
|
+
'https://localhost',
|
|
10
|
+
'https://127.0.0.1',
|
|
11
|
+
'https://[::1]',
|
|
12
|
+
]);
|
|
13
|
+
const MAX_BODY_BYTES = 1024 * 1024; // 1 MiB
|
|
14
|
+
const MAX_SSE_CLIENTS_DEFAULT = 32;
|
|
15
|
+
/** Header name SSE clients + RPC callers use to scope events. */
|
|
16
|
+
export const PUGI_CLIENT_ID_HEADER = 'x-pugi-client-id';
|
|
17
|
+
/**
|
|
18
|
+
* Start the HTTP+SSE transport. Returns a handle once the listener is
|
|
19
|
+
* bound — the caller can `await` the close hook for graceful shutdown.
|
|
20
|
+
*/
|
|
21
|
+
export async function serveHttp(options) {
|
|
22
|
+
const host = options.host ?? '127.0.0.1';
|
|
23
|
+
const log = options.log ?? (() => { });
|
|
24
|
+
const bearerTokenAutoGenerated = options.bearerToken === undefined;
|
|
25
|
+
const bearerToken = options.bearerToken ?? randomBytes(32).toString('hex');
|
|
26
|
+
const sseClients = new Set();
|
|
27
|
+
const corsOrigins = buildCorsOrigins(options.corsOrigins);
|
|
28
|
+
const maxSseClients = options.maxSseClients ?? MAX_SSE_CLIENTS_DEFAULT;
|
|
29
|
+
const tokenBuffer = Buffer.from(bearerToken, 'utf8');
|
|
30
|
+
// Bind the listener FIRST so we can resolve the effective Host header
|
|
31
|
+
// allowlist (the OS-assigned ephemeral port — when port=0 — is only
|
|
32
|
+
// known after listen). The createServer + listen split below preserves
|
|
33
|
+
// that ordering: handlers created here, allowed hosts computed after
|
|
34
|
+
// listening, then attached via the closure.
|
|
35
|
+
let allowedHosts = new Set();
|
|
36
|
+
const httpServer = createServer((req, res) => {
|
|
37
|
+
handleRequest({
|
|
38
|
+
req,
|
|
39
|
+
res,
|
|
40
|
+
mcpServer: options.server,
|
|
41
|
+
tokenBuffer,
|
|
42
|
+
sseClients,
|
|
43
|
+
corsOrigins,
|
|
44
|
+
allowedHosts,
|
|
45
|
+
maxSseClients,
|
|
46
|
+
log,
|
|
47
|
+
}).catch((error) => {
|
|
48
|
+
log('error', `unhandled http error: ${error.message}`);
|
|
49
|
+
if (!res.headersSent) {
|
|
50
|
+
res.statusCode = 500;
|
|
51
|
+
res.setHeader('Content-Type', 'application/json');
|
|
52
|
+
res.end(JSON.stringify({ error: 'internal_error', message: error.message }));
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
// Wire server events -> SSE broadcast. The payload may include a
|
|
57
|
+
// `clientId` (string) injected by the request dispatcher; if so we
|
|
58
|
+
// route the event only to the matching SSE client. Untagged events
|
|
59
|
+
// (no clientId) still broadcast — preserves the single-tenant
|
|
60
|
+
// operator workflow that does not bother to send the header.
|
|
61
|
+
const onToolCall = (payload) => {
|
|
62
|
+
routeSse(sseClients, 'tool_call', { name: payload.name, args: payload.args }, payload.clientId);
|
|
63
|
+
};
|
|
64
|
+
const onToolResult = (payload) => {
|
|
65
|
+
routeSse(sseClients, 'tool_result', { name: payload.name, ok: payload.ok, summary: payload.summary }, payload.clientId);
|
|
66
|
+
};
|
|
67
|
+
options.server.events.on('tool_call', onToolCall);
|
|
68
|
+
options.server.events.on('tool_result', onToolResult);
|
|
69
|
+
// Heartbeat — keep proxies + browser readers alive. 15s matches the
|
|
70
|
+
// admin-api SSE keepalive interval; same intermediary defenses (CDNs
|
|
71
|
+
// that drop quiet streams at ~30s). Heartbeats are untagged — every
|
|
72
|
+
// listener gets them.
|
|
73
|
+
const heartbeatTimer = setInterval(() => {
|
|
74
|
+
routeSse(sseClients, 'heartbeat', { ts: new Date().toISOString() }, undefined);
|
|
75
|
+
}, 15_000);
|
|
76
|
+
// Don't block process exit on the timer.
|
|
77
|
+
if (typeof heartbeatTimer.unref === 'function')
|
|
78
|
+
heartbeatTimer.unref();
|
|
79
|
+
// Bind the listener.
|
|
80
|
+
await new Promise((resolve, reject) => {
|
|
81
|
+
const onError = (error) => {
|
|
82
|
+
httpServer.off('listening', onListening);
|
|
83
|
+
reject(error);
|
|
84
|
+
};
|
|
85
|
+
const onListening = () => {
|
|
86
|
+
httpServer.off('error', onError);
|
|
87
|
+
resolve();
|
|
88
|
+
};
|
|
89
|
+
httpServer.once('error', onError);
|
|
90
|
+
httpServer.once('listening', onListening);
|
|
91
|
+
httpServer.listen(options.port, host);
|
|
92
|
+
});
|
|
93
|
+
// Compute the effective bound port + Host allowlist. `address()`
|
|
94
|
+
// returns the OS-assigned port when caller passed 0.
|
|
95
|
+
const address = httpServer.address();
|
|
96
|
+
const effectivePort = address && typeof address === 'object' ? address.port : options.port;
|
|
97
|
+
allowedHosts = buildAllowedHosts(host, effectivePort, options.allowedHosts);
|
|
98
|
+
const url = `http://${host}:${effectivePort}`;
|
|
99
|
+
log('info', `pugi mcp http listening at ${url}`);
|
|
100
|
+
const close = async () => {
|
|
101
|
+
clearInterval(heartbeatTimer);
|
|
102
|
+
options.server.events.off('tool_call', onToolCall);
|
|
103
|
+
options.server.events.off('tool_result', onToolResult);
|
|
104
|
+
for (const client of sseClients)
|
|
105
|
+
client.close();
|
|
106
|
+
sseClients.clear();
|
|
107
|
+
await new Promise((resolveClose) => httpServer.close(() => resolveClose()));
|
|
108
|
+
};
|
|
109
|
+
if (options.signal) {
|
|
110
|
+
if (options.signal.aborted) {
|
|
111
|
+
await close();
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
options.signal.addEventListener('abort', () => {
|
|
115
|
+
void close();
|
|
116
|
+
}, { once: true });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
url,
|
|
121
|
+
bearerToken,
|
|
122
|
+
bearerTokenAutoGenerated,
|
|
123
|
+
server: httpServer,
|
|
124
|
+
close,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async function handleRequest(input) {
|
|
128
|
+
const { req, res, mcpServer, tokenBuffer, sseClients, corsOrigins, allowedHosts, maxSseClients, log } = input;
|
|
129
|
+
// P0 #3 — Host header allowlist defends against DNS rebinding. The
|
|
130
|
+
// attacker page rebinds attacker.com → 127.0.0.1 (TTL=0), then issues
|
|
131
|
+
// a same-origin (`Host: attacker.com`) fetch. CORS does not gate
|
|
132
|
+
// same-origin requests; only a Host check stops it.
|
|
133
|
+
const hostHeader = headerString(req, 'host');
|
|
134
|
+
if (!hostHeader || !allowedHosts.has(hostHeader.toLowerCase())) {
|
|
135
|
+
// 421 Misdirected Request — the canonical HTTP code for "this server
|
|
136
|
+
// does not answer for that Host". Matches the Ollama / Jupyter
|
|
137
|
+
// mitigation choices for the same attack class.
|
|
138
|
+
res.statusCode = 421;
|
|
139
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
140
|
+
res.end(JSON.stringify({
|
|
141
|
+
error: 'host_not_allowed',
|
|
142
|
+
message: `pugi mcp serve: Host header "${hostHeader ?? '<missing>'}" is not in the allowlist`,
|
|
143
|
+
}));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
applyCorsHeaders(req, res, corsOrigins);
|
|
147
|
+
// Pre-flight.
|
|
148
|
+
if (req.method === 'OPTIONS') {
|
|
149
|
+
res.statusCode = 204;
|
|
150
|
+
res.end();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const url = req.url ?? '/';
|
|
154
|
+
// Strip query string for routing — endpoints are query-agnostic today.
|
|
155
|
+
const [pathnameRaw, queryString = ''] = url.split('?');
|
|
156
|
+
const pathname = pathnameRaw ?? '/';
|
|
157
|
+
if (pathname === '/mcp/v1/health' && req.method === 'GET') {
|
|
158
|
+
sendJson(res, 200, { ok: true, service: 'pugi-mcp', version: '0.1.0' });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// Auth gate for everything else.
|
|
162
|
+
if (!checkAuth(req, tokenBuffer)) {
|
|
163
|
+
sendJson(res, 401, {
|
|
164
|
+
error: 'auth_required',
|
|
165
|
+
message: 'missing or invalid Authorization: Bearer <token>',
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (pathname === '/mcp/v1/events' && req.method === 'GET') {
|
|
170
|
+
handleSse(req, res, sseClients, maxSseClients, queryString);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (req.method !== 'POST') {
|
|
174
|
+
sendJson(res, 405, { error: 'method_not_allowed', message: `use POST for ${pathname}` });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// P1 #4 — early Content-Length cap. Reject the body before we read a
|
|
178
|
+
// single byte so a 4 GB POST never reaches `readJsonBody`. Some
|
|
179
|
+
// clients (curl --data-binary @big.bin) omit Content-Length and chunk-
|
|
180
|
+
// encode; for those `readJsonBody` enforces the same cap mid-stream.
|
|
181
|
+
const declaredLength = Number.parseInt(headerString(req, 'content-length') ?? '', 10);
|
|
182
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
|
|
183
|
+
res.statusCode = 413;
|
|
184
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
185
|
+
res.end(JSON.stringify({
|
|
186
|
+
error: 'payload_too_large',
|
|
187
|
+
message: `request body declared ${declaredLength} bytes; cap is ${MAX_BODY_BYTES}`,
|
|
188
|
+
}));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
let body;
|
|
192
|
+
try {
|
|
193
|
+
body = await readJsonBody(req);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
sendJson(res, 400, {
|
|
197
|
+
error: 'invalid_json',
|
|
198
|
+
message: error instanceof Error ? error.message : String(error),
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// Resolve callerId for per-connection SSE scoping. Header beats query
|
|
203
|
+
// string; missing both means "untagged" (broadcast semantics preserved).
|
|
204
|
+
const callerId = resolveCallerId(req, queryString);
|
|
205
|
+
switch (pathname) {
|
|
206
|
+
case '/mcp/v1/initialize':
|
|
207
|
+
await handleRpcShortcut(res, mcpServer, 'initialize', body, callerId);
|
|
208
|
+
// β4 r2 P2 #4 — auto-complete the MCP handshake on behalf of
|
|
209
|
+
// shortcut clients. The MCP wire spec separates `initialize`
|
|
210
|
+
// (capabilities exchange) from `notifications/initialized` (client
|
|
211
|
+
// confirms it is ready), and the server's `requireInitialized`
|
|
212
|
+
// gate refuses `tools/call` until BOTH have fired. The shortcut
|
|
213
|
+
// endpoints abstract over JSON-RPC framing so callers (curl,
|
|
214
|
+
// Postman, ad-hoc fetch from a Worker) never see the second leg —
|
|
215
|
+
// we fire it ourselves so a `POST /initialize` followed by
|
|
216
|
+
// `POST /call` works as the shortcut surface promises. The raw
|
|
217
|
+
// `/rpc` endpoint still requires the explicit notification because
|
|
218
|
+
// its contract is "drive the wire protocol yourself".
|
|
219
|
+
await mcpServer
|
|
220
|
+
.handleMessage({
|
|
221
|
+
jsonrpc: '2.0',
|
|
222
|
+
method: 'notifications/initialized',
|
|
223
|
+
// No `id` — notifications never carry one. The server
|
|
224
|
+
// dispatcher returns null for notifications, so this never
|
|
225
|
+
// produces a response we'd need to drop.
|
|
226
|
+
...(callerId ? { meta: { clientId: callerId } } : {}),
|
|
227
|
+
})
|
|
228
|
+
.catch((error) => {
|
|
229
|
+
// Best-effort: a notification failure cannot fail the prior
|
|
230
|
+
// /initialize response (already written). Log and continue —
|
|
231
|
+
// the next /call will surface the underlying issue with a
|
|
232
|
+
// clean INVALID_REQUEST.
|
|
233
|
+
log('warn', `auto-initialized notification failed: ${error.message}`);
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
case '/mcp/v1/list':
|
|
237
|
+
await handleRpcShortcut(res, mcpServer, 'tools/list', body ?? {}, callerId);
|
|
238
|
+
return;
|
|
239
|
+
case '/mcp/v1/call':
|
|
240
|
+
await handleRpcShortcut(res, mcpServer, 'tools/call', body, callerId);
|
|
241
|
+
return;
|
|
242
|
+
case '/mcp/v1/rpc':
|
|
243
|
+
await handleRpcPassthrough(res, mcpServer, body, callerId, log);
|
|
244
|
+
return;
|
|
245
|
+
default:
|
|
246
|
+
sendJson(res, 404, { error: 'not_found', message: `unknown endpoint: ${pathname}` });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async function handleRpcShortcut(res, mcpServer, method, params, callerId) {
|
|
251
|
+
const request = {
|
|
252
|
+
jsonrpc: '2.0',
|
|
253
|
+
id: 1, // synthetic — the shortcut path never multiplexes
|
|
254
|
+
method,
|
|
255
|
+
...(params !== undefined ? { params } : {}),
|
|
256
|
+
...(callerId ? { meta: { clientId: callerId } } : {}),
|
|
257
|
+
};
|
|
258
|
+
const response = await mcpServer.handleMessage(request);
|
|
259
|
+
if (!response) {
|
|
260
|
+
sendJson(res, 204, null);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
// Map JSON-RPC errors to HTTP status to make the shortcut usable from
|
|
264
|
+
// curl + Postman without parsing the envelope.
|
|
265
|
+
const httpStatus = jsonRpcErrorToHttpStatus(response);
|
|
266
|
+
sendJson(res, httpStatus, response);
|
|
267
|
+
}
|
|
268
|
+
async function handleRpcPassthrough(res, mcpServer, body, callerId, log) {
|
|
269
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
270
|
+
sendJson(res, 400, {
|
|
271
|
+
jsonrpc: '2.0',
|
|
272
|
+
id: null,
|
|
273
|
+
error: { code: MCP_ERROR_CODES.INVALID_REQUEST, message: 'request body must be a JSON object' },
|
|
274
|
+
});
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const candidate = body;
|
|
278
|
+
if (candidate.jsonrpc !== '2.0' || typeof candidate.method !== 'string') {
|
|
279
|
+
sendJson(res, 400, {
|
|
280
|
+
jsonrpc: '2.0',
|
|
281
|
+
id: candidate.id ?? null,
|
|
282
|
+
error: {
|
|
283
|
+
code: MCP_ERROR_CODES.INVALID_REQUEST,
|
|
284
|
+
message: 'invalid JSON-RPC envelope: jsonrpc=2.0 + string method required',
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const request = {
|
|
290
|
+
jsonrpc: '2.0',
|
|
291
|
+
method: candidate.method,
|
|
292
|
+
...(candidate.id !== undefined ? { id: candidate.id } : {}),
|
|
293
|
+
...(candidate.params !== undefined ? { params: candidate.params } : {}),
|
|
294
|
+
...(callerId ? { meta: { clientId: callerId } } : {}),
|
|
295
|
+
};
|
|
296
|
+
try {
|
|
297
|
+
const response = await mcpServer.handleMessage(request);
|
|
298
|
+
if (!response) {
|
|
299
|
+
sendJson(res, 204, null);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
sendJson(res, jsonRpcErrorToHttpStatus(response), response);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
log('error', `rpc passthrough failed: ${error.message}`);
|
|
306
|
+
sendJson(res, 500, {
|
|
307
|
+
jsonrpc: '2.0',
|
|
308
|
+
id: candidate.id ?? null,
|
|
309
|
+
error: { code: MCP_ERROR_CODES.INTERNAL_ERROR, message: error.message },
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function jsonRpcErrorToHttpStatus(response) {
|
|
314
|
+
if (!('error' in response))
|
|
315
|
+
return 200;
|
|
316
|
+
switch (response.error.code) {
|
|
317
|
+
case MCP_ERROR_CODES.METHOD_NOT_FOUND:
|
|
318
|
+
return 404;
|
|
319
|
+
case MCP_ERROR_CODES.INVALID_REQUEST:
|
|
320
|
+
case MCP_ERROR_CODES.INVALID_PARAMS:
|
|
321
|
+
case MCP_ERROR_CODES.PARSE_ERROR:
|
|
322
|
+
return 400;
|
|
323
|
+
case MCP_ERROR_CODES.PERMISSION_REFUSED:
|
|
324
|
+
return 403;
|
|
325
|
+
case MCP_ERROR_CODES.AUTH_REQUIRED:
|
|
326
|
+
return 401;
|
|
327
|
+
default:
|
|
328
|
+
return 500;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function handleSse(req, res, sseClients, maxSseClients, queryString) {
|
|
332
|
+
// P1 #4 — connection cap. A misbehaving caller cannot accumulate
|
|
333
|
+
// dangling SSE handles indefinitely; the 33rd connection bounces.
|
|
334
|
+
if (sseClients.size >= maxSseClients) {
|
|
335
|
+
res.statusCode = 503;
|
|
336
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
337
|
+
res.end(JSON.stringify({
|
|
338
|
+
error: 'sse_capacity',
|
|
339
|
+
message: `pugi mcp serve: SSE client cap (${maxSseClients}) reached`,
|
|
340
|
+
}));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
res.statusCode = 200;
|
|
344
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
345
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
346
|
+
res.setHeader('Connection', 'keep-alive');
|
|
347
|
+
const clientId = resolveCallerId(req, queryString);
|
|
348
|
+
// Surface the assigned/observed clientId in a comment frame so the
|
|
349
|
+
// listener can correlate it with subsequent tool-call POSTs.
|
|
350
|
+
res.write(`:ready clientId=${clientId ?? ''}\n\n`);
|
|
351
|
+
const client = {
|
|
352
|
+
res,
|
|
353
|
+
clientId,
|
|
354
|
+
close: () => {
|
|
355
|
+
try {
|
|
356
|
+
res.end();
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// already closed
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
sseClients.add(client);
|
|
364
|
+
const cleanup = () => {
|
|
365
|
+
sseClients.delete(client);
|
|
366
|
+
};
|
|
367
|
+
req.on('close', cleanup);
|
|
368
|
+
req.on('error', cleanup);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Route an SSE event to the correct subset of clients.
|
|
372
|
+
*
|
|
373
|
+
* - `targetClientId` undefined → broadcast (every connected client).
|
|
374
|
+
* Used for heartbeats AND for tool events that omit the clientId
|
|
375
|
+
* header (single-tenant operator default).
|
|
376
|
+
* - `targetClientId` set → deliver only to clients that opened the
|
|
377
|
+
* stream with the matching clientId. Other listeners (different
|
|
378
|
+
* paired agents) do not see the event. This is the per-connection
|
|
379
|
+
* confidentiality scope (β4 r1 P1 #5).
|
|
380
|
+
*/
|
|
381
|
+
function routeSse(sseClients, event, data, targetClientId) {
|
|
382
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
383
|
+
for (const client of sseClients) {
|
|
384
|
+
if (targetClientId !== undefined && client.clientId !== targetClientId) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
client.res.write(payload);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// Best-effort — the close listener cleans up.
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/* ---------- helpers ---------------------------------------------------- */
|
|
396
|
+
function applyCorsHeaders(req, res, origins) {
|
|
397
|
+
const origin = headerString(req, 'origin');
|
|
398
|
+
// Resolve the request origin against the allowlist. If the request
|
|
399
|
+
// has no Origin header (curl, server-to-server) we skip CORS — the
|
|
400
|
+
// bearer-token gate is the actual auth boundary. We deliberately
|
|
401
|
+
// never emit `Access-Control-Allow-Credentials: true` — no endpoint
|
|
402
|
+
// uses cookies, and the credentialed-fetch hole it created (paired
|
|
403
|
+
// with port-agnostic origins) was the β4 r1 P0 #2 root cause.
|
|
404
|
+
if (origin && originAllowed(origin, origins)) {
|
|
405
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
406
|
+
res.setHeader('Vary', 'Origin');
|
|
407
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
408
|
+
res.setHeader('Access-Control-Allow-Headers', `Authorization, Content-Type, ${PUGI_CLIENT_ID_HEADER}`);
|
|
409
|
+
res.setHeader('Access-Control-Max-Age', '600');
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function originAllowed(origin, allowlist) {
|
|
413
|
+
// Exact match is the only safe gate. The previous implementation did
|
|
414
|
+
// `origin.startsWith(candidate + ':')` which whitelisted EVERY port on
|
|
415
|
+
// localhost — combined with credentialed fetch (now removed) it
|
|
416
|
+
// created a cross-origin read primitive for any locally-running web
|
|
417
|
+
// server. We now require the operator to add the exact origin
|
|
418
|
+
// (including port) via `corsOrigins`, OR rely on the bare-host
|
|
419
|
+
// localhost defaults (no port → matches the browser's `http://localhost`
|
|
420
|
+
// canonical form).
|
|
421
|
+
return allowlist.has(origin);
|
|
422
|
+
}
|
|
423
|
+
function buildCorsOrigins(extra) {
|
|
424
|
+
const set = new Set(DEFAULT_LOCALHOST_ORIGINS);
|
|
425
|
+
if (!extra)
|
|
426
|
+
return set;
|
|
427
|
+
for (const origin of extra) {
|
|
428
|
+
if (origin === '*') {
|
|
429
|
+
throw new Error('pugi mcp serve --http: wildcard CORS origin "*" is not supported (use a specific origin)');
|
|
430
|
+
}
|
|
431
|
+
set.add(origin);
|
|
432
|
+
}
|
|
433
|
+
return set;
|
|
434
|
+
}
|
|
435
|
+
function buildAllowedHosts(host, port, extra) {
|
|
436
|
+
// Standard local-only allowlist. Lowercase normalised because the
|
|
437
|
+
// Host header is case-insensitive per RFC 7230 §5.4 — we lowercase
|
|
438
|
+
// both sides at compare-time too.
|
|
439
|
+
const set = new Set([
|
|
440
|
+
`127.0.0.1:${port}`,
|
|
441
|
+
`localhost:${port}`,
|
|
442
|
+
`[::1]:${port}`,
|
|
443
|
+
// Bind host may be 0.0.0.0 / non-loopback — still register it so
|
|
444
|
+
// the operator's intentional broader bind keeps working.
|
|
445
|
+
`${host.toLowerCase()}:${port}`,
|
|
446
|
+
]);
|
|
447
|
+
if (extra) {
|
|
448
|
+
for (const entry of extra)
|
|
449
|
+
set.add(entry.toLowerCase());
|
|
450
|
+
}
|
|
451
|
+
return set;
|
|
452
|
+
}
|
|
453
|
+
function checkAuth(req, tokenBuffer) {
|
|
454
|
+
const header = headerString(req, 'authorization');
|
|
455
|
+
if (!header)
|
|
456
|
+
return false;
|
|
457
|
+
const match = /^Bearer\s+(.+)$/.exec(header.trim());
|
|
458
|
+
if (!match)
|
|
459
|
+
return false;
|
|
460
|
+
const supplied = Buffer.from(match[1] ?? '', 'utf8');
|
|
461
|
+
if (supplied.length !== tokenBuffer.length)
|
|
462
|
+
return false;
|
|
463
|
+
try {
|
|
464
|
+
return timingSafeEqual(supplied, tokenBuffer);
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function headerString(req, name) {
|
|
471
|
+
const value = req.headers[name];
|
|
472
|
+
if (Array.isArray(value))
|
|
473
|
+
return value[0] ?? null;
|
|
474
|
+
return value ?? null;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Resolve the caller's stable clientId. Header is the canonical channel
|
|
478
|
+
* (clients that POST a tool call can declare it programmatically); the
|
|
479
|
+
* query string is the fallback for SSE GETs because browsers cannot set
|
|
480
|
+
* custom headers on `EventSource`.
|
|
481
|
+
*
|
|
482
|
+
* β4 r2 P2 #5 — GET requests ALWAYS get a stable id (auto-assigned via
|
|
483
|
+
* randomUUID when the caller supplied neither header nor query). Before
|
|
484
|
+
* this fix the `if (!queryString) return undefined` guard short-circuited
|
|
485
|
+
* BEFORE the GET-auto-assign branch, so a bare `GET /mcp/v1/events`
|
|
486
|
+
* (no query string at all) landed in the "untagged broadcast" routing
|
|
487
|
+
* bucket and received events meant for OTHER tagged clients.
|
|
488
|
+
*
|
|
489
|
+
* POSTs that omit it stay untagged on purpose (single-tenant operator
|
|
490
|
+
* default — the dispatcher emits untagged tool events that broadcast).
|
|
491
|
+
*/
|
|
492
|
+
function resolveCallerId(req, queryString) {
|
|
493
|
+
const headerValue = headerString(req, PUGI_CLIENT_ID_HEADER);
|
|
494
|
+
if (headerValue && headerValue.trim().length > 0)
|
|
495
|
+
return headerValue.trim();
|
|
496
|
+
if (queryString) {
|
|
497
|
+
const params = new URLSearchParams(queryString);
|
|
498
|
+
const fromQuery = params.get('clientId');
|
|
499
|
+
if (fromQuery && fromQuery.trim().length > 0)
|
|
500
|
+
return fromQuery.trim();
|
|
501
|
+
}
|
|
502
|
+
// β4 r2 P2 #5 — bare GET (no header, no query, OR query without a
|
|
503
|
+
// clientId param) still gets an auto-id so the SSE listener never
|
|
504
|
+
// shares the broadcast bucket with another subscriber. The auto-id
|
|
505
|
+
// is surfaced via the `:ready clientId=<uuid>\n\n` SSE comment in
|
|
506
|
+
// handleSse so the listener can copy it into subsequent POSTs.
|
|
507
|
+
if (req.method === 'GET')
|
|
508
|
+
return randomUUID();
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
511
|
+
async function readJsonBody(req) {
|
|
512
|
+
const chunks = [];
|
|
513
|
+
let total = 0;
|
|
514
|
+
for await (const chunk of req) {
|
|
515
|
+
const buf = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
|
|
516
|
+
total += buf.length;
|
|
517
|
+
if (total > MAX_BODY_BYTES) {
|
|
518
|
+
throw new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`);
|
|
519
|
+
}
|
|
520
|
+
chunks.push(buf);
|
|
521
|
+
}
|
|
522
|
+
if (chunks.length === 0)
|
|
523
|
+
return undefined;
|
|
524
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
525
|
+
if (raw.trim().length === 0)
|
|
526
|
+
return undefined;
|
|
527
|
+
try {
|
|
528
|
+
return JSON.parse(raw);
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
throw new Error(`invalid JSON body: ${error.message}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
function sendJson(res, status, body) {
|
|
535
|
+
res.statusCode = status;
|
|
536
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
537
|
+
if (body === null) {
|
|
538
|
+
res.end();
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
res.end(JSON.stringify(body));
|
|
542
|
+
}
|
|
543
|
+
/* ---------- shared emitter for tests ----------------------------------- */
|
|
544
|
+
/**
|
|
545
|
+
* Internal helper exposed for tests: build an in-memory EventEmitter
|
|
546
|
+
* that mirrors the broadcast surface. The real server uses
|
|
547
|
+
* `mcpServer.events` directly; tests that want to drive synthetic
|
|
548
|
+
* events without a full MCP round-trip use this.
|
|
549
|
+
*/
|
|
550
|
+
export function createTestEventBus() {
|
|
551
|
+
return new EventEmitter();
|
|
552
|
+
}
|
|
553
|
+
//# sourceMappingURL=http-server.js.map
|