@reticlehq/server 2.9.0 → 2.11.0
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/SKILL.md +64 -26
- package/dist/capsule/capsule-store.d.ts +2 -2
- package/dist/cli/cli-parse.d.ts +1 -1
- package/dist/cli/cli-parse.js +1 -1
- package/dist/cli/cli-verify.js +1 -1
- package/dist/crawl/crawl.d.ts +7 -2
- package/dist/crawl/crawl.js +52 -9
- package/dist/dev/stale-issue-guard.d.ts +43 -0
- package/dist/dev/stale-issue-guard.js +75 -0
- package/dist/domain/domain-tools.js +3 -2
- package/dist/ee/audit-log.js +2 -2
- package/dist/events/body-failures.d.ts +2 -2
- package/dist/events/contradiction-folds.d.ts +45 -0
- package/dist/events/contradiction-folds.js +42 -0
- package/dist/events/contradictions.d.ts +19 -1
- package/dist/events/contradictions.js +17 -2
- package/dist/events/echo-mismatch.d.ts +2 -2
- package/dist/events/predicate-asks.d.ts +17 -0
- package/dist/events/predicate-asks.js +26 -0
- package/dist/events/predicate-eval.js +211 -24
- package/dist/events/predicate-parse.js +9 -3
- package/dist/events/predicate-schema.d.ts +18 -0
- package/dist/events/predicate-schema.js +55 -0
- package/dist/events/stale-response.d.ts +2 -3
- package/dist/events/stale-response.js +49 -2
- package/dist/events/unit-mismatch.d.ts +2 -2
- package/dist/flows/flow-tools.js +34 -5
- package/dist/flows/predicate-to-expect.js +8 -0
- package/dist/flows/verify-change-tools.js +6 -2
- package/dist/honesty/blind-spots.d.ts +9 -0
- package/dist/honesty/blind-spots.js +17 -4
- package/dist/honesty/gap-ledger.d.ts +52 -0
- package/dist/honesty/gap-ledger.js +63 -0
- package/dist/honesty/instrumentation-gaps.d.ts +55 -0
- package/dist/honesty/instrumentation-gaps.js +21 -0
- package/dist/honesty/observability.d.ts +37 -0
- package/dist/honesty/observability.js +40 -0
- package/dist/honesty/verified.js +16 -0
- package/dist/index.d.ts +18 -1
- package/dist/index.js +57 -0
- package/dist/init/mcp.d.ts +1 -2
- package/dist/init/mcp.js +4 -1
- package/dist/init/node-io.js +3 -0
- package/dist/init/run.d.ts +18 -0
- package/dist/init/run.js +34 -3
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +33 -10
- package/dist/journal/envelope.d.ts +24 -24
- package/dist/license/license.d.ts +76 -5
- package/dist/license/license.js +93 -29
- package/dist/mcp/mcp-proxy.d.ts +2 -27
- package/dist/mcp/mcp-proxy.js +4 -88
- package/dist/mcp/mcp.d.ts +18 -2
- package/dist/mcp/mcp.js +31 -10
- package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
- package/dist/mcp/proxy-daemon-probe.js +48 -0
- package/dist/mcp/proxy-handshake.js +1 -1
- package/dist/mcp/server-instructions.js +2 -2
- package/dist/mcp/sse-frame-parser.d.ts +19 -0
- package/dist/mcp/sse-frame-parser.js +41 -0
- package/dist/project/artifact-root.d.ts +83 -0
- package/dist/project/artifact-root.js +112 -0
- package/dist/project/memory-fs.d.ts +9 -0
- package/dist/project/memory-fs.js +88 -0
- package/dist/project/project-store.d.ts +17 -0
- package/dist/project/project-store.js +33 -0
- package/dist/project/remember-project.d.ts +24 -0
- package/dist/project/remember-project.js +40 -0
- package/dist/project/session-root.d.ts +15 -0
- package/dist/project/session-root.js +31 -0
- package/dist/runs/run-tools.js +2 -1
- package/dist/session/lease-availability.d.ts +40 -0
- package/dist/session/lease-availability.js +43 -0
- package/dist/session/no-session-diagnosis.d.ts +17 -0
- package/dist/session/no-session-diagnosis.js +41 -8
- package/dist/session/no-session-watch.js +7 -0
- package/dist/session/session-readiness.js +1 -1
- package/dist/session/session.d.ts +9 -0
- package/dist/session/session.js +9 -0
- package/dist/session/stall-clock.d.ts +18 -0
- package/dist/session/stall-clock.js +48 -0
- package/dist/telemetry/daemon-telemetry.js +16 -22
- package/dist/telemetry/license-activation.d.ts +48 -0
- package/dist/telemetry/license-activation.js +57 -0
- package/dist/telemetry/session-metrics.d.ts +1 -1
- package/dist/telemetry/session-metrics.js +1 -1
- package/dist/telemetry/telemetry.d.ts +12 -3
- package/dist/telemetry/telemetry.js +34 -5
- package/dist/tools/act-tools.js +34 -4
- package/dist/tools/assert-verdict.d.ts +2 -0
- package/dist/tools/assert-verdict.js +24 -1
- package/dist/tools/contract-tools.js +5 -3
- package/dist/tools/coverage-tools.js +33 -0
- package/dist/tools/dynamic-tools.js +1 -1
- package/dist/tools/error-recovery.js +1 -1
- package/dist/tools/feedback-tools.js +31 -6
- package/dist/tools/invoke-tool.d.ts +1 -1
- package/dist/tools/invoke-tool.js +10 -5
- package/dist/tools/observe-tools.js +8 -2
- package/dist/tools/tool-kit.d.ts +46 -4
- package/dist/tools/tool-names.d.ts +2 -0
- package/dist/tools/tool-names.js +2 -0
- package/dist/tools/tool-surface.js +2 -3
- package/dist/tools/tools.js +16 -1
- package/docs/README.md +1 -0
- package/docs/agent-cheatsheet.md +2 -2
- package/docs/capabilities.mdx +4 -4
- package/docs/cli/hunt.mdx +2 -2
- package/docs/deploy-checks.md +2 -2
- package/docs/docs.json +2 -2
- package/docs/enterprise.md +9 -3
- package/docs/flows.md +4 -4
- package/docs/gates.md +1 -1
- package/docs/license-activation.mdx +145 -0
- package/docs/recipes.mdx +2 -2
- package/docs/telemetry-contract.md +18 -12
- package/docs/telemetry-events.mdx +1 -34
- package/docs/telemetry.md +0 -1
- package/docs/tools-overview.mdx +1 -1
- package/docs/tools-session-and-feedback.mdx +5 -0
- package/docs/tools-tools-and-run.mdx +15 -15
- package/docs/usage.md +3 -3
- package/package.json +2 -2
- package/dist/telemetry/instrumentation-stall.d.ts +0 -50
- package/dist/telemetry/instrumentation-stall.js +0 -90
package/dist/license/license.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
|
|
14
14
|
import { z } from 'zod';
|
|
15
|
+
import { LicenseActivation } from '@reticlehq/core';
|
|
15
16
|
/** Outcome of verifying a license key. */
|
|
16
17
|
export const LicenseStatus = {
|
|
17
18
|
VALID: 'valid',
|
|
@@ -20,8 +21,19 @@ export const LicenseStatus = {
|
|
|
20
21
|
BAD_SIGNATURE: 'bad-signature',
|
|
21
22
|
EXPIRED: 'expired',
|
|
22
23
|
};
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* The signed claims inside a key. `exp` is epoch ms; `features` (optional) scopes which ee features
|
|
26
|
+
* unlock.
|
|
27
|
+
*
|
|
28
|
+
* `lid` is the STABLE LICENSE ID and the only safe join key for per-customer usage: `org` is a display
|
|
29
|
+
* name a human typed at signing time, so two customers called "Acme" merge into one and a customer who
|
|
30
|
+
* renames splits into two. Every key carries one (minted by the issuer, `scripts/issue-license.mjs`),
|
|
31
|
+
* which is why it is required rather than optional — an optional id is one that goes missing on exactly
|
|
32
|
+
* the keys somebody later needs to attribute, and a key without it should be re-issued, not silently
|
|
33
|
+
* counted as nobody.
|
|
34
|
+
*/
|
|
24
35
|
const LicensePayloadSchema = z.object({
|
|
36
|
+
lid: z.string().min(1),
|
|
25
37
|
org: z.string(),
|
|
26
38
|
plan: z.string(),
|
|
27
39
|
exp: z.number(),
|
|
@@ -68,40 +80,52 @@ export function verifyLicenseKey(key, publicKey, now) {
|
|
|
68
80
|
if (!signatureOk)
|
|
69
81
|
return { status: LicenseStatus.BAD_SIGNATURE };
|
|
70
82
|
if (payload.exp <= now)
|
|
71
|
-
return { status: LicenseStatus.EXPIRED };
|
|
83
|
+
return { status: LicenseStatus.EXPIRED, payload };
|
|
72
84
|
return { status: LicenseStatus.VALID, payload };
|
|
73
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Where to get a key. A named constant because it appears in the gate's error AND in what
|
|
88
|
+
* `reticle license` prints, and a contact address that drifts between the two is a support request
|
|
89
|
+
* that reaches nobody.
|
|
90
|
+
*/
|
|
91
|
+
export const LICENSE_CONTACT = 'hey@reticle.sh';
|
|
92
|
+
/**
|
|
93
|
+
* The features a licence gates in THIS build. The single source: `assertEnterprise` callers name a
|
|
94
|
+
* member rather than a string literal, and `reticle license` reports the list, so what the CLI claims
|
|
95
|
+
* is gated is derived from what is actually gated rather than a second list somebody maintains.
|
|
96
|
+
*
|
|
97
|
+
* It is deliberately short. Reticle's roadmap for enterprise is longer (SSO, SCIM, RBAC, multi-org,
|
|
98
|
+
* policy gates), and none of it is here yet — printing a roadmap as though it were shipped is how a
|
|
99
|
+
* buyer discovers on day two that the thing they paid for does not exist. What is listed is what the
|
|
100
|
+
* running build will actually refuse without a key.
|
|
101
|
+
*/
|
|
102
|
+
export const EnterpriseFeature = {
|
|
103
|
+
/** Recording an audit event. */
|
|
104
|
+
AUDIT_LOG: 'audit-log',
|
|
105
|
+
};
|
|
74
106
|
/** Thrown when a production-mode enterprise feature is used without a valid license. */
|
|
75
107
|
export class EnterpriseLicenseError extends Error {
|
|
76
108
|
feature;
|
|
77
109
|
reason;
|
|
78
110
|
constructor(feature, reason) {
|
|
79
|
-
super(`Reticle Enterprise feature "${feature}" requires a valid license (${reason}). Contact
|
|
111
|
+
super(`Reticle Enterprise feature "${feature}" requires a valid license (${reason}). Contact ${LICENSE_CONTACT}.`);
|
|
80
112
|
this.name = 'EnterpriseLicenseError';
|
|
81
113
|
this.feature = feature;
|
|
82
114
|
this.reason = reason;
|
|
83
115
|
}
|
|
84
116
|
}
|
|
85
|
-
/** The issuer public key from the environment (set at release / by the operator); undefined if unset. */
|
|
86
|
-
function issuerPublicKey() {
|
|
87
|
-
const pem = process.env[LICENSE_PUBLIC_KEY_ENV];
|
|
88
|
-
if (pem === undefined || 0 === pem.length)
|
|
89
|
-
return undefined;
|
|
90
|
-
try {
|
|
91
|
-
return createPublicKey(pem);
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
return undefined;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
117
|
/**
|
|
98
118
|
* Gate an enterprise feature. No-op in dev/eval (requireLicense:false). In production it throws
|
|
99
119
|
* EnterpriseLicenseError unless a valid, unexpired key that covers `feature` is present.
|
|
100
120
|
*/
|
|
101
|
-
export function assertEnterprise(feature, ctx) {
|
|
121
|
+
export function assertEnterprise(feature, ctx, env = process.env, baked) {
|
|
102
122
|
if (!ctx.requireLicense)
|
|
103
123
|
return;
|
|
104
|
-
|
|
124
|
+
// Resolved exactly as the env-entry point resolves it: BAKED first, env only as the dev/test
|
|
125
|
+
// hatch. This path used to read the environment alone, so on a real release, where the key is
|
|
126
|
+
// baked and no env var is set, it denied every valid licence with `no-issuer-key` while
|
|
127
|
+
// assertEnterpriseFromEnv allowed the same key on the same build. Two gates, one truth.
|
|
128
|
+
const publicKey = ctx.publicKey ?? loadPublicKey(resolveIssuerPublicKeyPem(env, baked));
|
|
105
129
|
if (publicKey === undefined)
|
|
106
130
|
throw new EnterpriseLicenseError(feature, 'no-issuer-key');
|
|
107
131
|
const check = verifyLicenseKey(ctx.key, publicKey, ctx.now());
|
|
@@ -122,7 +146,7 @@ export const LICENSE_PUBLIC_KEY_ENV = 'RETICLE_LICENSE_PUBLIC_KEY';
|
|
|
122
146
|
* never setting RETICLE_LICENSE_PUBLIC_KEY, because the baked key takes precedence. The public key is
|
|
123
147
|
* safe to ship openly — it can only verify licenses, never mint them (that needs the private key).
|
|
124
148
|
*/
|
|
125
|
-
const BAKED_ISSUER_PUBLIC_KEY_PEM =
|
|
149
|
+
const BAKED_ISSUER_PUBLIC_KEY_PEM = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAcg+u61Fs5pwDHcYxFGhaxVaQoUbAdyl+RUdSViZyxDY=\n-----END PUBLIC KEY-----";
|
|
126
150
|
/**
|
|
127
151
|
* Resolve the issuer public-key PEM enforcement uses: the baked-in release key wins, so it can't be
|
|
128
152
|
* turned off from the environment. Only when nothing is baked (dev/repo) does the env var apply — that
|
|
@@ -134,6 +158,15 @@ function resolveIssuerPublicKeyPem(env, baked = BAKED_ISSUER_PUBLIC_KEY_PEM) {
|
|
|
134
158
|
const envPem = env[LICENSE_PUBLIC_KEY_ENV];
|
|
135
159
|
return envPem !== undefined && envPem.length > 0 ? envPem : undefined;
|
|
136
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* The two fields every report carries, spread into each branch. One object rather than a field on each
|
|
163
|
+
* return, because the value that must never go missing is the one on the branch nobody was thinking
|
|
164
|
+
* about — and the branch an unlicensed reader lands on is exactly that branch.
|
|
165
|
+
*/
|
|
166
|
+
const LICENSE_OFFER = {
|
|
167
|
+
gated: Object.values(EnterpriseFeature),
|
|
168
|
+
contact: LICENSE_CONTACT,
|
|
169
|
+
};
|
|
137
170
|
function loadPublicKey(pem) {
|
|
138
171
|
if (pem === undefined || 0 === pem.length)
|
|
139
172
|
return undefined;
|
|
@@ -156,42 +189,73 @@ export function describeLicense(now, env = process.env, baked = BAKED_ISSUER_PUB
|
|
|
156
189
|
// is off, and assertEnterpriseFromEnv now denies rather than unlocking. Say so loudly here too.
|
|
157
190
|
return isProductionEnv(env)
|
|
158
191
|
? {
|
|
159
|
-
|
|
192
|
+
...LICENSE_OFFER,
|
|
193
|
+
status: LicenseActivation.INVALID,
|
|
160
194
|
detail: 'MISCONFIGURED — running in production with no issuer key baked; enterprise features are DENIED (rebuild with BAKED_ISSUER_PUBLIC_KEY_PEM stamped in)',
|
|
161
195
|
}
|
|
162
196
|
: {
|
|
163
|
-
|
|
197
|
+
...LICENSE_OFFER,
|
|
198
|
+
status: LicenseActivation.EVAL,
|
|
164
199
|
detail: 'evaluation mode: enterprise features run free (no issuer key configured)',
|
|
165
200
|
};
|
|
166
201
|
}
|
|
167
202
|
const publicKey = loadPublicKey(pem);
|
|
168
203
|
if (publicKey === undefined)
|
|
169
|
-
return {
|
|
204
|
+
return {
|
|
205
|
+
...LICENSE_OFFER,
|
|
206
|
+
status: LicenseActivation.INVALID,
|
|
207
|
+
detail: `${LICENSE_PUBLIC_KEY_ENV} is not a valid public key`,
|
|
208
|
+
};
|
|
170
209
|
const check = verifyLicenseKey(env[LICENSE_KEY_ENV], publicKey, now);
|
|
171
210
|
if (check.status === LicenseStatus.VALID) {
|
|
172
|
-
const { org, plan, exp, features } = check.payload;
|
|
211
|
+
const { lid, org, plan, exp, features } = check.payload;
|
|
212
|
+
// A key scoped to features this build does not gate is `active` and unlocks nothing. Both halves
|
|
213
|
+
// were already on screen (`features` against `gated`) and nothing joined them, so the one word a
|
|
214
|
+
// customer actually reads told them they were fine while every gated call refused them.
|
|
215
|
+
const coversNothingHere = features !== undefined && !features.some((f) => LICENSE_OFFER.gated.includes(f));
|
|
216
|
+
const detail = coversNothingHere
|
|
217
|
+
? `licensed to ${org} (${plan}), expires ${new Date(exp).toISOString()}. This key covers ${features?.join(', ')}, and nothing this build gates (${LICENSE_OFFER.gated.join(', ')}) is included. Contact ${LICENSE_CONTACT}`
|
|
218
|
+
: `licensed to ${org} (${plan}), expires ${new Date(exp).toISOString()}`;
|
|
173
219
|
return {
|
|
174
|
-
|
|
220
|
+
contact: LICENSE_CONTACT,
|
|
221
|
+
// The one active case that still needs the list: a key covering nothing this build gates is
|
|
222
|
+
// reported here, and the sentence explaining it is unreadable without both sides named.
|
|
223
|
+
...(coversNothingHere ? { gated: LICENSE_OFFER.gated } : {}),
|
|
224
|
+
status: LicenseActivation.ACTIVE,
|
|
225
|
+
licenseId: lid,
|
|
175
226
|
org,
|
|
176
227
|
plan,
|
|
177
228
|
expiresAt: exp,
|
|
178
229
|
...(features !== undefined ? { features } : {}),
|
|
179
|
-
|
|
230
|
+
...(coversNothingHere ? { coversNothingHere } : {}),
|
|
231
|
+
detail,
|
|
180
232
|
};
|
|
181
233
|
}
|
|
182
234
|
if (check.status === LicenseStatus.MISSING) {
|
|
183
235
|
return {
|
|
184
|
-
|
|
185
|
-
|
|
236
|
+
...LICENSE_OFFER,
|
|
237
|
+
status: LicenseActivation.MISSING,
|
|
238
|
+
detail: `set ${LICENSE_KEY_ENV} to activate enterprise features in production. ` +
|
|
239
|
+
`Request a key from ${LICENSE_CONTACT}`,
|
|
186
240
|
};
|
|
187
241
|
}
|
|
188
242
|
if (check.status === LicenseStatus.EXPIRED) {
|
|
243
|
+
// The id rides an expired key so a lapse can be attributed to a CUSTOMER. Without it, telemetry
|
|
244
|
+
// reports that somebody's licence ran out and cannot say whose, which is not a renewal signal.
|
|
189
245
|
return {
|
|
190
|
-
|
|
191
|
-
|
|
246
|
+
...LICENSE_OFFER,
|
|
247
|
+
status: LicenseActivation.EXPIRED,
|
|
248
|
+
licenseId: check.payload.lid,
|
|
249
|
+
org: check.payload.org,
|
|
250
|
+
expiresAt: check.payload.exp,
|
|
251
|
+
detail: `license expired. Renew with ${LICENSE_CONTACT} to keep using enterprise features`,
|
|
192
252
|
};
|
|
193
253
|
}
|
|
194
|
-
return {
|
|
254
|
+
return {
|
|
255
|
+
...LICENSE_OFFER,
|
|
256
|
+
status: LicenseActivation.INVALID,
|
|
257
|
+
detail: `license key rejected (${check.status}). ${LICENSE_CONTACT} can re-issue it`,
|
|
258
|
+
};
|
|
195
259
|
}
|
|
196
260
|
/** True in a production runtime (NODE_ENV=production) — where a missing issuer key is a mis-built
|
|
197
261
|
* release, not an eval session, so the gate must fail CLOSED rather than run features free. */
|
package/dist/mcp/mcp-proxy.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { SseFrameParser, type SseFrame } from './sse-frame-parser.js';
|
|
2
|
+
export { probeDaemon, waitForDaemon } from './proxy-daemon-probe.js';
|
|
1
3
|
/**
|
|
2
4
|
* Re-exported so every existing caller (and every spec) keeps importing the proxy's log from the
|
|
3
5
|
* proxy. Where the code lives is a file-size decision; where it is imported from is an API.
|
|
@@ -92,33 +94,6 @@ export declare class HandshakeReplay {
|
|
|
92
94
|
/** True when this inbound daemon line is the echo of our replayed handshake and must not be forwarded. */
|
|
93
95
|
shouldSuppressInbound(line: string): boolean;
|
|
94
96
|
}
|
|
95
|
-
/** One parsed Server-Sent-Events frame: the event name (defaulted to "message") and its data. */
|
|
96
|
-
interface SseFrame {
|
|
97
|
-
event: string;
|
|
98
|
-
data: string;
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* Incremental SSE frame parser for the MCP front door.
|
|
102
|
-
*
|
|
103
|
-
* A single SSE field can split across TCP reads (`da` in one chunk, `ta: {...}` in the next), and a
|
|
104
|
-
* server may send CRLF or bare CR line endings — so the framing is stateful and edge-case-prone, yet it
|
|
105
|
-
* carried every MCP message the agent sends and was only ever exercised end-to-end. Pulled out of the
|
|
106
|
-
* socket handler as a pure, chunk-fed parser so those boundaries are unit-testable: feed raw chunks,
|
|
107
|
-
* get back each complete frame (a blank line terminates a frame; `event:` names it, `data:` lines
|
|
108
|
-
* accumulate newline-joined; `id:`/`retry:`/comments are ignored — not needed for the bridge).
|
|
109
|
-
*/
|
|
110
|
-
export declare class SseFrameParser {
|
|
111
|
-
#private;
|
|
112
|
-
push(chunk: string): SseFrame[];
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* Returns true if something is already listening on the reticle port.
|
|
116
|
-
* Uses a plain TCP probe so we don't create a side-effectful SSE session
|
|
117
|
-
* inside the daemon just to check reachability.
|
|
118
|
-
*/
|
|
119
|
-
export declare function probeDaemon(port: number): Promise<boolean>;
|
|
120
|
-
/** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
|
|
121
|
-
export declare function waitForDaemon(port: number): Promise<void>;
|
|
122
97
|
/**
|
|
123
98
|
* Turn an `endpoint` frame's data into a POST target, or null when there is nothing usable in it.
|
|
124
99
|
*
|
package/dist/mcp/mcp-proxy.js
CHANGED
|
@@ -2,8 +2,11 @@ import * as http from 'node:http';
|
|
|
2
2
|
import { localInitializeResponse, isHandshakeLine, drainLines, MAX_STDIN_LINE_BYTES, } from './proxy-handshake.js';
|
|
3
3
|
import { ToolCatalogCache } from './tool-catalog-cache.js';
|
|
4
4
|
import { toolsChangedNotification } from './proxy-handshake.js';
|
|
5
|
-
import * as net from 'node:net';
|
|
6
5
|
import { LOOPBACK_HOST, MCP_SSE_PATH, MCP_SHUTDOWN_EVENT, ReticleEnv, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
|
|
6
|
+
import { SseFrameParser } from './sse-frame-parser.js';
|
|
7
|
+
export { SseFrameParser } from './sse-frame-parser.js';
|
|
8
|
+
export { probeDaemon, waitForDaemon } from './proxy-daemon-probe.js';
|
|
9
|
+
import { probeDaemon } from './proxy-daemon-probe.js';
|
|
7
10
|
import { SERVER_VERSION } from '../version/server-version.js';
|
|
8
11
|
import { buildServerInstructions } from './server-instructions.js';
|
|
9
12
|
import { hasProjectConnectedBefore } from '../session/connection-memory.js';
|
|
@@ -26,20 +29,6 @@ export { accountProxyLogWrite, proxyLog, proxyLogPath, PROXY_LOG_CHECK_BYTES, re
|
|
|
26
29
|
import { proxyLog } from './proxy-log.js';
|
|
27
30
|
import { log } from '../log.js';
|
|
28
31
|
import { OutageReason, OutageStage, reportMcpOutage } from './mcp-outage.js';
|
|
29
|
-
const DEFAULT_DAEMON_READY_TIMEOUT_MS = 10_000;
|
|
30
|
-
/**
|
|
31
|
-
* How long to wait for the spawned daemon's port to accept connections before giving up. The default
|
|
32
|
-
* suits a normal machine; a slow CI/VM (heavy headless-browser launch) can raise it via the
|
|
33
|
-
* RETICLE_DAEMON_READY_TIMEOUT_MS env var. Invalid/absent values fall back to the default.
|
|
34
|
-
*/
|
|
35
|
-
const envDaemonReadyTimeoutMs = Number(process.env['RETICLE_DAEMON_READY_TIMEOUT_MS']);
|
|
36
|
-
const DAEMON_READY_TIMEOUT_MS = Number.isFinite(envDaemonReadyTimeoutMs) && envDaemonReadyTimeoutMs > 0
|
|
37
|
-
? envDaemonReadyTimeoutMs
|
|
38
|
-
: DEFAULT_DAEMON_READY_TIMEOUT_MS;
|
|
39
|
-
const DAEMON_POLL_INTERVAL_MS = 100;
|
|
40
|
-
function delay(ms) {
|
|
41
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
42
|
-
}
|
|
43
32
|
/** Reconnect backoff: linear, capped, so a briefly-restarting daemon is picked up fast. */
|
|
44
33
|
const RECONNECT_BASE_MS = 250;
|
|
45
34
|
const RECONNECT_CAP_MS = 5_000;
|
|
@@ -264,79 +253,6 @@ export class HandshakeReplay {
|
|
|
264
253
|
return true;
|
|
265
254
|
}
|
|
266
255
|
}
|
|
267
|
-
/**
|
|
268
|
-
* Incremental SSE frame parser for the MCP front door.
|
|
269
|
-
*
|
|
270
|
-
* A single SSE field can split across TCP reads (`da` in one chunk, `ta: {...}` in the next), and a
|
|
271
|
-
* server may send CRLF or bare CR line endings — so the framing is stateful and edge-case-prone, yet it
|
|
272
|
-
* carried every MCP message the agent sends and was only ever exercised end-to-end. Pulled out of the
|
|
273
|
-
* socket handler as a pure, chunk-fed parser so those boundaries are unit-testable: feed raw chunks,
|
|
274
|
-
* get back each complete frame (a blank line terminates a frame; `event:` names it, `data:` lines
|
|
275
|
-
* accumulate newline-joined; `id:`/`retry:`/comments are ignored — not needed for the bridge).
|
|
276
|
-
*/
|
|
277
|
-
export class SseFrameParser {
|
|
278
|
-
#buffer = '';
|
|
279
|
-
#event = '';
|
|
280
|
-
#data = '';
|
|
281
|
-
push(chunk) {
|
|
282
|
-
this.#buffer += chunk;
|
|
283
|
-
// Normalise CRLF/CR so the splitter only handles \n, then hold the trailing partial line for the
|
|
284
|
-
// next chunk (it may complete later).
|
|
285
|
-
const normalised = this.#buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
286
|
-
const lines = normalised.split('\n');
|
|
287
|
-
this.#buffer = lines.pop() ?? '';
|
|
288
|
-
const frames = [];
|
|
289
|
-
for (const line of lines) {
|
|
290
|
-
if ('' === line) {
|
|
291
|
-
if (this.#data !== '') {
|
|
292
|
-
frames.push({ event: this.#event !== '' ? this.#event : 'message', data: this.#data });
|
|
293
|
-
}
|
|
294
|
-
this.#event = '';
|
|
295
|
-
this.#data = '';
|
|
296
|
-
}
|
|
297
|
-
else if (line.startsWith('event:')) {
|
|
298
|
-
this.#event = line.slice(6).trim();
|
|
299
|
-
}
|
|
300
|
-
else if (line.startsWith('data:')) {
|
|
301
|
-
const val = line.slice(5).trim();
|
|
302
|
-
this.#data = this.#data !== '' ? `${this.#data}\n${val}` : val;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
return frames;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
/**
|
|
309
|
-
* Returns true if something is already listening on the reticle port.
|
|
310
|
-
* Uses a plain TCP probe so we don't create a side-effectful SSE session
|
|
311
|
-
* inside the daemon just to check reachability.
|
|
312
|
-
*/
|
|
313
|
-
export function probeDaemon(port) {
|
|
314
|
-
return new Promise((resolve) => {
|
|
315
|
-
const socket = new net.Socket();
|
|
316
|
-
socket.setTimeout(500);
|
|
317
|
-
socket.on('connect', () => {
|
|
318
|
-
socket.destroy();
|
|
319
|
-
resolve(true);
|
|
320
|
-
});
|
|
321
|
-
socket.on('error', () => resolve(false));
|
|
322
|
-
socket.on('timeout', () => {
|
|
323
|
-
socket.destroy();
|
|
324
|
-
resolve(false);
|
|
325
|
-
});
|
|
326
|
-
socket.connect(port, LOOPBACK_HOST);
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
/** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
|
|
330
|
-
export async function waitForDaemon(port) {
|
|
331
|
-
const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
|
|
332
|
-
while (Date.now() < deadline) {
|
|
333
|
-
const reachable = await probeDaemon(port);
|
|
334
|
-
if (reachable)
|
|
335
|
-
return;
|
|
336
|
-
await delay(DAEMON_POLL_INTERVAL_MS);
|
|
337
|
-
}
|
|
338
|
-
throw new Error(`reticle daemon did not become ready on port ${port} within ${DAEMON_READY_TIMEOUT_MS}ms`);
|
|
339
|
-
}
|
|
340
256
|
/**
|
|
341
257
|
* POST one JSON-RPC line into the daemon's session. Resolves null on success, or a short reason
|
|
342
258
|
* when the request never reached a server that will answer it.
|
package/dist/mcp/mcp.d.ts
CHANGED
|
@@ -35,7 +35,14 @@ export declare function encodeResult(result: unknown, encoding: string): string;
|
|
|
35
35
|
* "Call reticle_tools { names: [...] } for its parameters" — leaving it out made our own advice a
|
|
36
36
|
* dead end on the one surface that advertises everything.
|
|
37
37
|
*/
|
|
38
|
-
export declare function advertisedTools(surface: ToolSurface
|
|
38
|
+
export declare function advertisedTools(surface: ToolSurface,
|
|
39
|
+
/**
|
|
40
|
+
* The full table to advertise from. Defaults to what this package ships.
|
|
41
|
+
*
|
|
42
|
+
* A consumer embedding the engine passes its own composition — ours plus its own — so its tools
|
|
43
|
+
* reach the wire without editing the shipped array.
|
|
44
|
+
*/
|
|
45
|
+
tools?: readonly ToolDef[]): ToolDef[];
|
|
39
46
|
/**
|
|
40
47
|
* Exactly what gets advertised for one tool under a profile: trimmed description, lean input shape,
|
|
41
48
|
* and (non-lean only) the output schema.
|
|
@@ -59,4 +66,13 @@ export declare function createMcpServer(deps: ToolDeps, profile?: ToolSurface,
|
|
|
59
66
|
* told to instrument a project that is already wired loses a paragraph, while an agent not told
|
|
60
67
|
* loses the entire install — and the second is what the field overwhelmingly shows.
|
|
61
68
|
*/
|
|
62
|
-
previouslyConnected?: boolean
|
|
69
|
+
previouslyConnected?: boolean,
|
|
70
|
+
/**
|
|
71
|
+
* The full tool table this server serves. Defaults to what this package ships.
|
|
72
|
+
*
|
|
73
|
+
* Threaded rather than read from the module so a consumer can serve its own tools on one surface
|
|
74
|
+
* with ours. It reaches all three places that need the WHOLE table and not just the advertised
|
|
75
|
+
* slice: the profile filter, the `reticle_run` hatch, and the unadvertised-tool help — a consumer
|
|
76
|
+
* tool missing from either of the last two is a tool the agent is actively told does not exist.
|
|
77
|
+
*/
|
|
78
|
+
tools?: readonly ToolDef[]): McpServer;
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { PredicateSchema } from '../events/predicate.js';
|
|
5
|
-
import { isToonable, resultToToon } from '@reticlehq/core';
|
|
5
|
+
import { isToonable, resultToToon, MCP_SERVER_NAME } from '@reticlehq/core';
|
|
6
6
|
import { TOOLS } from '../tools/tools.js';
|
|
7
7
|
import { filterTools, describeToolSurface, TOOL_SURFACE, } from '../tools/tool-surface.js';
|
|
8
8
|
import { ReticleTool } from '../tools/tool-names.js';
|
|
@@ -18,7 +18,6 @@ import { unadvertisedToolHelp } from '../tools/unadvertised-help.js';
|
|
|
18
18
|
const CALL_TOOL_METHOD = 'tools/call';
|
|
19
19
|
import { log } from '../log.js';
|
|
20
20
|
import { SERVER_VERSION } from '../version/server-version.js';
|
|
21
|
-
import { MCP_SERVER_NAME } from '../init/mcp.js';
|
|
22
21
|
import { setMcpClientNameHook } from '../telemetry/feedback-context.js';
|
|
23
22
|
import { getSessionMetrics } from '../telemetry/session-metrics.js';
|
|
24
23
|
import { parsePredicate } from '../events/predicate-parse.js';
|
|
@@ -201,11 +200,24 @@ export function encodeResult(result, encoding) {
|
|
|
201
200
|
* "Call reticle_tools { names: [...] } for its parameters" — leaving it out made our own advice a
|
|
202
201
|
* dead end on the one surface that advertises everything.
|
|
203
202
|
*/
|
|
204
|
-
export function advertisedTools(surface
|
|
203
|
+
export function advertisedTools(surface,
|
|
204
|
+
/**
|
|
205
|
+
* The full table to advertise from. Defaults to what this package ships.
|
|
206
|
+
*
|
|
207
|
+
* A consumer embedding the engine passes its own composition — ours plus its own — so its tools
|
|
208
|
+
* reach the wire without editing the shipped array.
|
|
209
|
+
*/
|
|
210
|
+
tools = TOOLS) {
|
|
205
211
|
// The catalog reports which surface is live and what chose it — the only way an agent can see that
|
|
206
212
|
// a setting did not take (the daemon read the value it started with).
|
|
207
213
|
const origin = describeToolSurface(surface);
|
|
208
|
-
|
|
214
|
+
// The profile trims OUR table only. A tool this package does not ship was appended deliberately by
|
|
215
|
+
// whoever is embedding us, and our `CORE_TOOL_NAMES` was never going to contain its name — so
|
|
216
|
+
// filtering it would drop every consumer tool and look exactly like the feature not working.
|
|
217
|
+
const shipped = new Set(TOOLS.map((tool) => tool.name));
|
|
218
|
+
const ours = tools.filter((tool) => shipped.has(tool.name));
|
|
219
|
+
const theirs = tools.filter((tool) => !shipped.has(tool.name));
|
|
220
|
+
return [...filterTools([...ours], surface), ...theirs, ...buildDynamicTools([...tools], origin)];
|
|
209
221
|
}
|
|
210
222
|
/**
|
|
211
223
|
* Exactly what gets advertised for one tool under a profile: trimmed description, lean input shape,
|
|
@@ -364,7 +376,16 @@ export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT,
|
|
|
364
376
|
* told to instrument a project that is already wired loses a paragraph, while an agent not told
|
|
365
377
|
* loses the entire install — and the second is what the field overwhelmingly shows.
|
|
366
378
|
*/
|
|
367
|
-
previouslyConnected = false
|
|
379
|
+
previouslyConnected = false,
|
|
380
|
+
/**
|
|
381
|
+
* The full tool table this server serves. Defaults to what this package ships.
|
|
382
|
+
*
|
|
383
|
+
* Threaded rather than read from the module so a consumer can serve its own tools on one surface
|
|
384
|
+
* with ours. It reaches all three places that need the WHOLE table and not just the advertised
|
|
385
|
+
* slice: the profile filter, the `reticle_run` hatch, and the unadvertised-tool help — a consumer
|
|
386
|
+
* tool missing from either of the last two is a tool the agent is actively told does not exist.
|
|
387
|
+
*/
|
|
388
|
+
tools = TOOLS) {
|
|
368
389
|
const encoding = (process.env[ENCODING_ENV] ?? '').toLowerCase();
|
|
369
390
|
// Which surface this daemon advertises. The 18-tool default and the 48-tool full surface are
|
|
370
391
|
// different products from inside an agent's context, so outcomes are only comparable when the
|
|
@@ -412,13 +433,13 @@ previouslyConnected = false) {
|
|
|
412
433
|
// 11 tools were unreachable with no escape hatch — including reticle_annotate, which
|
|
413
434
|
// reticle_flow_save's own description instructs the agent to call. `full` advertises everything
|
|
414
435
|
// directly and needs no hatch.
|
|
415
|
-
const advertised = advertisedTools(profile);
|
|
436
|
+
const advertised = advertisedTools(profile, tools);
|
|
416
437
|
installFriendlyArgErrors(server, new Map(advertised
|
|
417
438
|
.filter((tool) => tool.example !== undefined)
|
|
418
439
|
.map((tool) => [tool.name, JSON.stringify(tool.example)])),
|
|
419
|
-
// Every tool in
|
|
420
|
-
// so an unadvertised tool reached through the hatch must be just as strict.
|
|
421
|
-
new Map(
|
|
440
|
+
// Every tool in the table, not just the advertised ones: reticle_run dispatches to the full
|
|
441
|
+
// table, so an unadvertised tool reached through the hatch must be just as strict.
|
|
442
|
+
new Map(tools.map((tool) => [tool.name, new Set(Object.keys(tool.inputSchema))])));
|
|
422
443
|
for (const tool of advertised) {
|
|
423
444
|
// Output schemas are now the largest slice of the per-request tax (55.6% of the hybrid payload
|
|
424
445
|
// after the predicate fix) and we are the only one of the three MCP servers that sends them.
|
|
@@ -480,6 +501,6 @@ previouslyConnected = false) {
|
|
|
480
501
|
// `tools/call` dispatcher lazily on the FIRST registerTool, so wrapping before this point finds
|
|
481
502
|
// nothing to wrap and silently does nothing. Verified live rather than assumed — the first attempt
|
|
482
503
|
// sat here doing exactly that.
|
|
483
|
-
installUnadvertisedToolHelp(server, new Set(advertised.map((tool) => tool.name)), new Set(
|
|
504
|
+
installUnadvertisedToolHelp(server, new Set(advertised.map((tool) => tool.name)), new Set(tools.map((tool) => tool.name)));
|
|
484
505
|
return server;
|
|
485
506
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true if something is already listening on the reticle port.
|
|
3
|
+
* Uses a plain TCP probe so we don't create a side-effectful SSE session
|
|
4
|
+
* inside the daemon just to check reachability.
|
|
5
|
+
*/
|
|
6
|
+
export declare function probeDaemon(port: number): Promise<boolean>;
|
|
7
|
+
/** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
|
|
8
|
+
export declare function waitForDaemon(port: number): Promise<void>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as net from 'node:net';
|
|
2
|
+
import { LOOPBACK_HOST } from '@reticlehq/core';
|
|
3
|
+
const DEFAULT_DAEMON_READY_TIMEOUT_MS = 10_000;
|
|
4
|
+
/**
|
|
5
|
+
* How long to wait for the spawned daemon's port to accept connections before giving up. The default
|
|
6
|
+
* suits a normal machine; a slow CI/VM (heavy headless-browser launch) can raise it via the
|
|
7
|
+
* RETICLE_DAEMON_READY_TIMEOUT_MS env var. Invalid/absent values fall back to the default.
|
|
8
|
+
*/
|
|
9
|
+
const envDaemonReadyTimeoutMs = Number(process.env['RETICLE_DAEMON_READY_TIMEOUT_MS']);
|
|
10
|
+
const DAEMON_READY_TIMEOUT_MS = Number.isFinite(envDaemonReadyTimeoutMs) && envDaemonReadyTimeoutMs > 0
|
|
11
|
+
? envDaemonReadyTimeoutMs
|
|
12
|
+
: DEFAULT_DAEMON_READY_TIMEOUT_MS;
|
|
13
|
+
const DAEMON_POLL_INTERVAL_MS = 100;
|
|
14
|
+
function delay(ms) {
|
|
15
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Returns true if something is already listening on the reticle port.
|
|
19
|
+
* Uses a plain TCP probe so we don't create a side-effectful SSE session
|
|
20
|
+
* inside the daemon just to check reachability.
|
|
21
|
+
*/
|
|
22
|
+
export function probeDaemon(port) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const socket = new net.Socket();
|
|
25
|
+
socket.setTimeout(500);
|
|
26
|
+
socket.on('connect', () => {
|
|
27
|
+
socket.destroy();
|
|
28
|
+
resolve(true);
|
|
29
|
+
});
|
|
30
|
+
socket.on('error', () => resolve(false));
|
|
31
|
+
socket.on('timeout', () => {
|
|
32
|
+
socket.destroy();
|
|
33
|
+
resolve(false);
|
|
34
|
+
});
|
|
35
|
+
socket.connect(port, LOOPBACK_HOST);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
|
|
39
|
+
export async function waitForDaemon(port) {
|
|
40
|
+
const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
|
|
41
|
+
while (Date.now() < deadline) {
|
|
42
|
+
const reachable = await probeDaemon(port);
|
|
43
|
+
if (reachable)
|
|
44
|
+
return;
|
|
45
|
+
await delay(DAEMON_POLL_INTERVAL_MS);
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`reticle daemon did not become ready on port ${port} within ${DAEMON_READY_TIMEOUT_MS}ms`);
|
|
48
|
+
}
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* Safe because the proxy replays the client's own `initialize` to the daemon whenever a session is
|
|
15
15
|
* finally established (see `replayLines`), so the daemon still gets its handshake in order.
|
|
16
16
|
*/
|
|
17
|
-
import { MCP_SERVER_NAME } from '../init/mcp.js';
|
|
18
17
|
import { SERVER_VERSION } from '../version/server-version.js';
|
|
18
|
+
import { MCP_SERVER_NAME } from '@reticlehq/core';
|
|
19
19
|
/** The version we answer with if the client proposed none. */
|
|
20
20
|
const FALLBACK_PROTOCOL_VERSION = '2024-11-05';
|
|
21
21
|
/**
|
|
@@ -22,7 +22,7 @@ import { SHARED_PARAM_GUIDANCE } from './shared-params.js';
|
|
|
22
22
|
/** The tools, and the rule that only two of them decide anything. Constant across both states. */
|
|
23
23
|
const VERDICT_DISCIPLINE = `Reticle verifies a running web app from the inside: look (reticle_snapshot / reticle_query), act and prove in one hop (reticle_act_and_wait), observe (reticle_state / reticle_network / reticle_console), assert (reticle_assert). Verify a user-facing change against the real app before you call it done, and never weaken a check to make it pass.
|
|
24
24
|
|
|
25
|
-
Only reticle_act_and_wait and reticle_assert produce a verdict. reticle_act and everything else move or read the app and prove nothing, so a drive that ends without one of those two has no result however many tools it used. Prefer reticle_act_and_wait({ ref, action, until }) — it names the expected consequence BEFORE the action, which is the difference between a check and a rationalisation.
|
|
25
|
+
Only reticle_act_and_wait and reticle_assert produce a verdict. reticle_act and everything else move or read the app and prove nothing, so a drive that ends without one of those two has no result however many tools it used. Prefer reticle_act_and_wait({ ref, action, until }) — it names the expected consequence BEFORE the action, which is the difference between a check and a rationalisation. Only verified:"yes" is a pass — "unknown" means Reticle could not tell what happened, "no-fault" means nothing was declared to prove. Report either as not proved.`;
|
|
26
26
|
/**
|
|
27
27
|
* The first move, for a project no app has ever connected to.
|
|
28
28
|
*
|
|
@@ -41,7 +41,7 @@ const FEEDBACK_ASK = `Feedback is first-class here, not a courtesy. Reticle is b
|
|
|
41
41
|
- You wished it could do something, or something works but costs too many calls (\`feature_request\` / \`improvement\`).
|
|
42
42
|
- The install, the wiring, or the setup itself gave you trouble — including anything you had to work around by hand.
|
|
43
43
|
|
|
44
|
-
Nothing is too minor, and a report costs one call. If the tools are unreachable (setup unfinished, daemon down), file the same report from the shell instead: \`reticle feedback --agent --kind <
|
|
44
|
+
Nothing is too minor, and a report costs one call. If the tools are unreachable (setup unfinished, daemon down), file the same report from the shell instead: \`reticle feedback --agent --kind <one of those kinds> "what happened"\`. Report defects in RETICLE — a bug you find in the app under test is Reticle working, and belongs in your answer to the user.`;
|
|
45
45
|
/** The instructions this daemon should advertise, given what it knows about the project. */
|
|
46
46
|
/**
|
|
47
47
|
* Instructions are sent ONCE at initialize; the tool surface is re-sent every turn. Measured on this
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** One parsed Server-Sent-Events frame: the event name (defaulted to "message") and its data. */
|
|
2
|
+
export interface SseFrame {
|
|
3
|
+
event: string;
|
|
4
|
+
data: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Incremental SSE frame parser for the MCP front door.
|
|
8
|
+
*
|
|
9
|
+
* A single SSE field can split across TCP reads (`da` in one chunk, `ta: {...}` in the next), and a
|
|
10
|
+
* server may send CRLF or bare CR line endings — so the framing is stateful and edge-case-prone, yet it
|
|
11
|
+
* carried every MCP message the agent sends and was only ever exercised end-to-end. Pulled out of the
|
|
12
|
+
* socket handler as a pure, chunk-fed parser so those boundaries are unit-testable: feed raw chunks,
|
|
13
|
+
* get back each complete frame (a blank line terminates a frame; `event:` names it, `data:` lines
|
|
14
|
+
* accumulate newline-joined; `id:`/`retry:`/comments are ignored — not needed for the bridge).
|
|
15
|
+
*/
|
|
16
|
+
export declare class SseFrameParser {
|
|
17
|
+
#private;
|
|
18
|
+
push(chunk: string): SseFrame[];
|
|
19
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental SSE frame parser for the MCP front door.
|
|
3
|
+
*
|
|
4
|
+
* A single SSE field can split across TCP reads (`da` in one chunk, `ta: {...}` in the next), and a
|
|
5
|
+
* server may send CRLF or bare CR line endings — so the framing is stateful and edge-case-prone, yet it
|
|
6
|
+
* carried every MCP message the agent sends and was only ever exercised end-to-end. Pulled out of the
|
|
7
|
+
* socket handler as a pure, chunk-fed parser so those boundaries are unit-testable: feed raw chunks,
|
|
8
|
+
* get back each complete frame (a blank line terminates a frame; `event:` names it, `data:` lines
|
|
9
|
+
* accumulate newline-joined; `id:`/`retry:`/comments are ignored — not needed for the bridge).
|
|
10
|
+
*/
|
|
11
|
+
export class SseFrameParser {
|
|
12
|
+
#buffer = '';
|
|
13
|
+
#event = '';
|
|
14
|
+
#data = '';
|
|
15
|
+
push(chunk) {
|
|
16
|
+
this.#buffer += chunk;
|
|
17
|
+
// Normalise CRLF/CR so the splitter only handles \n, then hold the trailing partial line for the
|
|
18
|
+
// next chunk (it may complete later).
|
|
19
|
+
const normalised = this.#buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
20
|
+
const lines = normalised.split('\n');
|
|
21
|
+
this.#buffer = lines.pop() ?? '';
|
|
22
|
+
const frames = [];
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
if ('' === line) {
|
|
25
|
+
if (this.#data !== '') {
|
|
26
|
+
frames.push({ event: this.#event !== '' ? this.#event : 'message', data: this.#data });
|
|
27
|
+
}
|
|
28
|
+
this.#event = '';
|
|
29
|
+
this.#data = '';
|
|
30
|
+
}
|
|
31
|
+
else if (line.startsWith('event:')) {
|
|
32
|
+
this.#event = line.slice(6).trim();
|
|
33
|
+
}
|
|
34
|
+
else if (line.startsWith('data:')) {
|
|
35
|
+
const val = line.slice(5).trim();
|
|
36
|
+
this.#data = this.#data !== '' ? `${this.#data}\n${val}` : val;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return frames;
|
|
40
|
+
}
|
|
41
|
+
}
|