@reticlehq/server 2.8.0 → 2.10.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 +130 -813
- package/dist/bridge/bridge.d.ts +23 -0
- package/dist/bridge/bridge.js +80 -2
- package/dist/bridge/bridge.test-harness.d.ts +5 -0
- package/dist/bridge/bridge.test-harness.js +6 -4
- package/dist/capsule/capsule-store.d.ts +2 -2
- package/dist/capsule/causal-summary.d.ts +16 -1
- package/dist/capsule/causal-summary.js +2 -1
- package/dist/cli/cli-doctor.js +41 -13
- package/dist/cli/cli-flow-commands.js +8 -8
- package/dist/cli/cli-launch.d.ts +0 -17
- package/dist/cli/cli-launch.js +0 -28
- package/dist/cli/cli-parse.d.ts +1 -1
- package/dist/cli/cli-parse.js +1 -1
- package/dist/cli/cli-port.d.ts +24 -0
- package/dist/cli/cli-port.js +66 -35
- package/dist/cli/cli-verify.d.ts +21 -0
- package/dist/cli/cli-verify.js +45 -1
- package/dist/cli/config-discovery.d.ts +35 -0
- package/dist/cli/config-discovery.js +163 -0
- package/dist/cli/doctor-daemon-line.js +4 -1
- package/dist/cli/doctor-project-line.d.ts +37 -0
- package/dist/cli/doctor-project-line.js +35 -0
- package/dist/cli/doctor-rows.d.ts +51 -0
- package/dist/cli/doctor-rows.js +54 -0
- package/dist/cli/doctor-sessions-line.js +6 -3
- package/dist/cli/drive-attach.d.ts +99 -0
- package/dist/cli/drive-attach.js +203 -0
- package/dist/cli/drive-command.d.ts +13 -0
- package/dist/cli/drive-command.js +103 -0
- package/dist/cli/open-note.d.ts +20 -0
- package/dist/cli/open-note.js +37 -0
- package/dist/cli/status-next-action.d.ts +37 -0
- package/dist/cli/status-next-action.js +61 -0
- package/dist/cli.js +65 -70
- package/dist/crawl/crawl.d.ts +7 -2
- package/dist/crawl/crawl.js +52 -9
- 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 +51 -1
- package/dist/events/contradictions.js +26 -7
- package/dist/events/declared.d.ts +45 -0
- package/dist/events/declared.js +73 -0
- package/dist/events/echo-mismatch.d.ts +9 -3
- package/dist/events/echo-mismatch.js +33 -5
- package/dist/events/event-filters.d.ts +1 -1
- package/dist/events/event-filters.js +7 -4
- package/dist/events/predicate-eval.d.ts +17 -91
- package/dist/events/predicate-eval.js +85 -326
- package/dist/events/predicate-parse.js +31 -3
- package/dist/events/predicate-route.d.ts +32 -0
- package/dist/events/predicate-route.js +102 -0
- package/dist/events/predicate-schema.d.ts +99 -0
- package/dist/events/predicate-schema.js +309 -0
- package/dist/events/predicate.d.ts +8 -0
- package/dist/events/predicate.js +127 -5
- 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/annotate-tools.js +3 -0
- package/dist/flows/annotate.js +18 -4
- package/dist/flows/coverage.d.ts +38 -5
- package/dist/flows/coverage.js +30 -4
- package/dist/flows/decision.d.ts +15 -0
- package/dist/flows/decision.js +1 -1
- package/dist/flows/flow-replay-run.js +8 -1
- package/dist/flows/flow-tools.js +9 -1
- package/dist/flows/replay.js +3 -0
- package/dist/flows/verify-change-tools.js +1 -1
- package/dist/honesty/accepted-write.d.ts +3 -0
- package/dist/honesty/accepted-write.js +8 -3
- package/dist/honesty/already-true.js +15 -0
- package/dist/honesty/blind-spots.js +4 -0
- package/dist/honesty/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +1 -0
- package/dist/honesty/uncaptured-bodies.d.ts +4 -2
- package/dist/honesty/uncaptured-bodies.js +9 -2
- package/dist/honesty/unread-outcome.d.ts +8 -0
- package/dist/honesty/unread-outcome.js +36 -9
- package/dist/honesty/unsettled.d.ts +9 -0
- package/dist/honesty/unsettled.js +7 -3
- package/dist/honesty/verified.d.ts +29 -3
- package/dist/honesty/verified.js +67 -8
- package/dist/http-server.d.ts +19 -0
- package/dist/http-server.js +202 -4
- package/dist/impact/impact-recorder.d.ts +23 -0
- package/dist/impact/impact-recorder.js +76 -0
- package/dist/impact/impact-store.d.ts +47 -0
- package/dist/impact/impact-store.js +171 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +60 -10
- package/dist/init/agent-rules.d.ts +20 -0
- package/dist/init/agent-rules.js +36 -0
- package/dist/init/astro-patch.js +27 -1
- package/dist/init/confirm.d.ts +21 -3
- package/dist/init/confirm.js +32 -5
- package/dist/init/connect-steps.d.ts +2 -0
- package/dist/init/connect-steps.js +50 -0
- package/dist/init/csp-check.d.ts +38 -0
- package/dist/init/csp-check.js +92 -0
- package/dist/init/csp-doctor.d.ts +25 -0
- package/dist/init/csp-doctor.js +49 -0
- package/dist/init/cursor.js +6 -9
- package/dist/init/detect.d.ts +8 -0
- package/dist/init/detect.js +33 -3
- package/dist/init/mcp-clients.d.ts +1 -0
- package/dist/init/mcp-clients.js +51 -7
- package/dist/init/mcp.d.ts +16 -2
- package/dist/init/mcp.js +23 -1
- package/dist/init/plan-framework.d.ts +10 -0
- package/dist/init/plan-framework.js +25 -1
- package/dist/init/plan.d.ts +7 -2
- package/dist/init/plan.js +66 -30
- package/dist/init/run.js +11 -5
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +12 -4
- package/dist/init/snippets.d.ts +1 -1
- package/dist/init/snippets.js +35 -7
- package/dist/init/vite-config.js +24 -2
- package/dist/input/network-mock-tools.js +8 -1
- package/dist/input/scroll-find.d.ts +6 -0
- package/dist/input/scroll-find.js +17 -2
- package/dist/input/scroll-tools.js +4 -0
- package/dist/journal/attach-journal.d.ts +9 -1
- package/dist/journal/attach-journal.js +13 -2
- package/dist/journal/journal-query.d.ts +13 -0
- package/dist/journal/journal-query.js +28 -3
- package/dist/journal/workspace-gitignore.d.ts +10 -0
- package/dist/journal/workspace-gitignore.js +59 -0
- package/dist/license/license.d.ts +69 -4
- package/dist/license/license.js +84 -15
- package/dist/mcp/mcp-proxy.d.ts +7 -93
- package/dist/mcp/mcp-proxy.js +183 -250
- package/dist/mcp/mcp.d.ts +27 -2
- package/dist/mcp/mcp.js +77 -36
- package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
- package/dist/mcp/proxy-daemon-probe.js +48 -0
- package/dist/mcp/proxy-handshake.d.ts +82 -1
- package/dist/mcp/proxy-handshake.js +110 -3
- package/dist/mcp/proxy-lifecycle.d.ts +10 -1
- package/dist/mcp/proxy-lifecycle.js +11 -2
- package/dist/mcp/proxy-log.d.ts +63 -0
- package/dist/mcp/proxy-log.js +132 -0
- package/dist/mcp/server-instructions.d.ts +17 -0
- package/dist/mcp/server-instructions.js +56 -0
- package/dist/mcp/shared-params.d.ts +27 -0
- package/dist/mcp/shared-params.js +36 -0
- package/dist/mcp/sse-frame-parser.d.ts +19 -0
- package/dist/mcp/sse-frame-parser.js +41 -0
- package/dist/project/reticle-dir.d.ts +2 -0
- package/dist/project/reticle-dir.js +1 -0
- package/dist/session/connection-memory.d.ts +59 -0
- package/dist/session/connection-memory.js +108 -0
- package/dist/session/dev-command.d.ts +26 -0
- package/dist/session/dev-command.js +89 -0
- package/dist/session/dev-server-probe.d.ts +68 -0
- package/dist/session/dev-server-probe.js +102 -7
- package/dist/session/last-act.d.ts +26 -0
- package/dist/session/last-act.js +35 -1
- package/dist/session/live-control-tools.js +42 -5
- package/dist/session/no-session-diagnosis.d.ts +55 -2
- package/dist/session/no-session-diagnosis.js +199 -22
- package/dist/session/no-session-next-action.d.ts +46 -0
- package/dist/session/no-session-next-action.js +85 -0
- package/dist/session/no-session-watch.d.ts +40 -1
- package/dist/session/no-session-watch.js +172 -11
- package/dist/session/pending-commands.d.ts +12 -0
- package/dist/session/pending-commands.js +13 -1
- package/dist/session/session-manager.d.ts +4 -0
- package/dist/session/session-manager.js +35 -5
- package/dist/session/session-readiness.js +1 -1
- package/dist/session/session.d.ts +10 -1
- package/dist/session/session.js +146 -5
- package/dist/telemetry/app-instrumented.d.ts +8 -0
- package/dist/telemetry/app-instrumented.js +10 -0
- package/dist/telemetry/bug-found.d.ts +18 -0
- package/dist/telemetry/bug-found.js +14 -0
- package/dist/telemetry/daemon-telemetry.js +29 -0
- package/dist/telemetry/install-source.d.ts +20 -0
- package/dist/telemetry/install-source.js +34 -0
- package/dist/telemetry/instrumentation-stall.d.ts +57 -0
- package/dist/telemetry/instrumentation-stall.js +103 -0
- package/dist/telemetry/license-activation.d.ts +48 -0
- package/dist/telemetry/license-activation.js +57 -0
- package/dist/telemetry/session-metrics.d.ts +8 -0
- package/dist/telemetry/session-metrics.js +10 -0
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +23 -3
- package/dist/tools/act-tools.js +166 -29
- package/dist/tools/assert-verdict.js +32 -7
- package/dist/tools/browser-tools.js +10 -0
- package/dist/tools/dynamic-tools.js +17 -8
- package/dist/tools/error-recovery.js +10 -6
- package/dist/tools/feedback-tools.js +31 -6
- package/dist/tools/invoke-tool.d.ts +1 -1
- package/dist/tools/invoke-tool.js +30 -8
- package/dist/tools/lease-hint.d.ts +30 -8
- package/dist/tools/lease-hint.js +65 -14
- package/dist/tools/lease-tools.d.ts +11 -0
- package/dist/tools/lease-tools.js +141 -96
- package/dist/tools/observe-tools.js +9 -3
- package/dist/tools/react-grace.d.ts +4 -0
- package/dist/tools/react-grace.js +6 -3
- package/dist/tools/read-tools.js +47 -1
- package/dist/tools/resolve-target.d.ts +33 -0
- package/dist/tools/resolve-target.js +53 -0
- package/dist/tools/settle-in-flight.d.ts +15 -0
- package/dist/tools/settle-in-flight.js +42 -2
- package/dist/tools/snapshot-delta.d.ts +3 -0
- package/dist/tools/snapshot-delta.js +67 -6
- package/dist/tools/tool-kit.d.ts +38 -3
- package/dist/tools/tool-names.d.ts +2 -0
- package/dist/tools/tool-names.js +2 -0
- package/dist/tools/tool-surface.d.ts +60 -0
- package/dist/tools/tool-surface.js +84 -19
- package/dist/tools/tools.js +61 -5
- package/dist/tools/unadvertised-help.js +9 -6
- package/docs/README.md +1 -2
- package/docs/actions.mdx +41 -8
- package/docs/agent-cheatsheet.md +6 -4
- package/docs/architecture.md +6 -0
- package/docs/benchmarks.md +60 -12
- package/docs/best-practices.mdx +11 -6
- package/docs/capabilities.mdx +204 -57
- package/docs/cli/affected.mdx +6 -4
- package/docs/cli/capsules.mdx +9 -8
- package/docs/cli/cloud.mdx +30 -4
- package/docs/cli/doctor.mdx +41 -8
- package/docs/cli/drive.mdx +34 -15
- package/docs/cli/gate.mdx +6 -6
- package/docs/cli/help.mdx +8 -6
- package/docs/cli/hunt.mdx +20 -4
- package/docs/cli/identify.mdx +16 -2
- package/docs/cli/init.mdx +35 -8
- package/docs/cli/kill.mdx +5 -5
- package/docs/cli/license.mdx +1 -1
- package/docs/cli/open.mdx +6 -0
- package/docs/cli/restart.mdx +6 -6
- package/docs/cli/serve.mdx +10 -3
- package/docs/cli/status.mdx +29 -5
- package/docs/cli/stop.mdx +8 -0
- package/docs/cli/verify.mdx +20 -1
- package/docs/cli/version.mdx +1 -1
- package/docs/cli/watch.mdx +9 -2
- package/docs/cli.mdx +44 -22
- package/docs/debugging.md +3 -1
- package/docs/deploy-checks.md +31 -2
- package/docs/desktop.mdx +276 -80
- package/docs/docs.json +13 -12
- package/docs/enterprise.md +9 -3
- package/docs/faq.mdx +49 -35
- package/docs/fixtures.md +2 -0
- package/docs/flows.md +7 -5
- package/docs/for-agents.md +3 -3
- package/docs/frameworks.mdx +46 -7
- package/docs/gate-plan.md +4 -2
- package/docs/gates.md +8 -6
- package/docs/getting-started.md +53 -12
- package/docs/human-control.md +2 -0
- package/docs/index.mdx +43 -1
- package/docs/install-agentic.mdx +13 -4
- package/docs/install-manual.mdx +6 -4
- package/docs/instrumentation.mdx +7 -1
- package/docs/integration-patterns.md +3 -1
- package/docs/local-registry.md +4 -2
- package/docs/multi-agent-testing.md +3 -1
- package/docs/packages/babel-plugin.mdx +15 -6
- package/docs/packages/browser.mdx +2 -2
- package/docs/packages/core.mdx +26 -16
- package/docs/packages/electron.mdx +5 -7
- package/docs/packages/eslint-plugin.mdx +5 -3
- package/docs/packages/next.mdx +3 -5
- package/docs/packages/react.mdx +3 -3
- package/docs/packages/server.mdx +2 -2
- package/docs/packages/tauri.mdx +6 -6
- package/docs/packages/test.mdx +18 -7
- package/docs/packages/vite-plugin.mdx +4 -6
- package/docs/packages.mdx +14 -13
- package/docs/platform-integration.md +2 -0
- package/docs/predicates.mdx +76 -7
- package/docs/quickstart.mdx +51 -5
- package/docs/recipes.mdx +63 -23
- package/docs/skill-file.mdx +3 -1
- package/docs/state-management.mdx +2 -0
- package/docs/system-map.md +3 -1
- package/docs/telemetry-contract.md +33 -5
- package/docs/telemetry-events.mdx +63 -18
- package/docs/telemetry.md +8 -5
- package/docs/testing.md +35 -1
- package/docs/token-efficiency.md +8 -2
- package/docs/tools-act-and-wait.mdx +64 -14
- package/docs/tools-act-sequence.mdx +34 -17
- package/docs/tools-act.mdx +24 -11
- package/docs/tools-assert.mdx +69 -10
- package/docs/tools-console.mdx +11 -7
- package/docs/tools-inspect.mdx +11 -8
- package/docs/tools-navigate.mdx +41 -5
- package/docs/tools-network.mdx +42 -9
- package/docs/tools-observe.mdx +66 -10
- package/docs/tools-overview.mdx +26 -23
- package/docs/tools-query.mdx +52 -8
- package/docs/tools-session-and-feedback.mdx +35 -9
- package/docs/tools-sessions.mdx +39 -10
- package/docs/tools-snapshot.mdx +57 -3
- package/docs/tools-state.mdx +24 -1
- package/docs/tools-tools-and-run.mdx +55 -24
- package/docs/tools-wait-for.mdx +34 -15
- package/docs/troubleshooting.mdx +80 -10
- package/docs/usage.md +46 -24
- package/docs/vs-chrome-devtools-mcp.mdx +41 -0
- package/docs/vs-playwright-mcp.mdx +42 -2
- package/docs/vs-screenshots.mdx +42 -1
- package/docs/what-is-reticle.mdx +1 -1
- package/docs/why-reticle.mdx +3 -1
- package/package.json +5 -5
- package/docs/desktop-apps.md +0 -265
package/dist/mcp/mcp.js
CHANGED
|
@@ -2,20 +2,22 @@ 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
|
+
import { ReticleTool } from '../tools/tool-names.js';
|
|
9
|
+
import { SHARED_PARAM_SHORT } from './shared-params.js';
|
|
8
10
|
import { buildDynamicTools } from '../tools/dynamic-tools.js';
|
|
9
11
|
import { runTool, SESSION_BOUND_TOOLS } from '../tools/invoke-tool.js';
|
|
10
12
|
import { sessionEnvelopeShape } from '../tools/tool-kit.js';
|
|
11
13
|
import { buildErrorPayload } from '../tools/error-recovery.js';
|
|
12
14
|
import { resultIsError } from './mcp-is-error.js';
|
|
15
|
+
import { buildServerInstructions } from './server-instructions.js';
|
|
13
16
|
import { unadvertisedToolHelp } from '../tools/unadvertised-help.js';
|
|
14
17
|
/** The JSON-RPC method the SDK registers its tool dispatcher under. */
|
|
15
18
|
const CALL_TOOL_METHOD = 'tools/call';
|
|
16
19
|
import { log } from '../log.js';
|
|
17
20
|
import { SERVER_VERSION } from '../version/server-version.js';
|
|
18
|
-
import { MCP_SERVER_NAME } from '../init/mcp.js';
|
|
19
21
|
import { setMcpClientNameHook } from '../telemetry/feedback-context.js';
|
|
20
22
|
import { getSessionMetrics } from '../telemetry/session-metrics.js';
|
|
21
23
|
import { parsePredicate } from '../events/predicate-parse.js';
|
|
@@ -31,27 +33,6 @@ export function withSessionEnvelope(name, outputSchema) {
|
|
|
31
33
|
return { ...sessionEnvelopeShape, ...outputSchema };
|
|
32
34
|
}
|
|
33
35
|
const SERVER_INFO = { name: MCP_SERVER_NAME, version: SERVER_VERSION };
|
|
34
|
-
/**
|
|
35
|
-
* What every agent is told at `initialize`, before it has called a single tool.
|
|
36
|
-
*
|
|
37
|
-
* This is the only channel that reaches an agent with NO dependency on the project: no CLAUDE.md, no
|
|
38
|
-
* AGENTS.md, no pasted skill, no successful `reticle init`. An agent whose setup half-worked has read
|
|
39
|
-
* none of those files and is precisely the agent whose report we need — so the standing instruction
|
|
40
|
-
* to send feedback has to live here, where it arrives with the connection itself.
|
|
41
|
-
*
|
|
42
|
-
* Kept to a handful of lines: it is prepended to every session's context, so each sentence is paid
|
|
43
|
-
* for on every turn, forever.
|
|
44
|
-
*/
|
|
45
|
-
const SERVER_INSTRUCTIONS = `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.
|
|
46
|
-
|
|
47
|
-
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. A verdict of verified:"unknown" is not a pass: it means Reticle drove the app and could not tell what happened. Report it as unknown.
|
|
48
|
-
|
|
49
|
-
Feedback is first-class here, not a courtesy. Reticle is built FOR agents, so your experience is the only signal that decides what gets built or fixed — and it disappears when your context does. Send it with \`reticle_feedback\` the moment you notice, then carry on with your task:
|
|
50
|
-
- Reticle broke, lied, or could not see something you needed (\`bug\` / \`gap\` / \`ambiguity\`).
|
|
51
|
-
- You wished it could do something, or something works but costs too many calls (\`feature_request\` / \`improvement\`).
|
|
52
|
-
- The install, the wiring, or the setup itself gave you trouble — including anything you had to work around by hand.
|
|
53
|
-
|
|
54
|
-
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 <bug|gap|ambiguity|feature_request|improvement> "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.`;
|
|
55
36
|
/** First sentence of a description (purpose only) for lean profiles — keeps per-turn def cost
|
|
56
37
|
* down. Cuts at the first sentence-ending period or newline; falls back to a 160-char cap. The
|
|
57
38
|
* first clause retains the essentials (enum hints like "role | text | label" live there). */
|
|
@@ -144,8 +125,10 @@ const PREDICATE_KINDS = 'Predicate object: { kind, ...fields }. kind is one of e
|
|
|
144
125
|
* paying for six.
|
|
145
126
|
*/
|
|
146
127
|
const PREDICATE_FIELD_GRAMMAR_HINT = ' Bug-catching options: net.count (exact request count — catches double-submit), ' +
|
|
147
|
-
'
|
|
148
|
-
'
|
|
128
|
+
'net.bodyContains (a substring of what the SERVER answered — catches a UI that echoes the input ' +
|
|
129
|
+
'it sent instead of the result it got back), console.absent:true (action completed with a CLEAN ' +
|
|
130
|
+
'console), absent:true on element/text (it should be gone). Call reticle_tools for the full field ' +
|
|
131
|
+
'grammar of a kind.';
|
|
149
132
|
const COMPACT_PREDICATE_DESCRIPTION = `${PREDICATE_KINDS}${PREDICATE_FIELD_GRAMMAR_HINT}`;
|
|
150
133
|
/**
|
|
151
134
|
* Lean copy of a tool's zod input shape for lean profiles: each parameter's description is
|
|
@@ -170,6 +153,16 @@ function leanZodShape(shape, predicateAnchor) {
|
|
|
170
153
|
out[key] = schema.isOptional() ? compact.optional() : compact;
|
|
171
154
|
continue;
|
|
172
155
|
}
|
|
156
|
+
// Shared boilerplate is documented once in the initialize instructions instead of on every
|
|
157
|
+
// tool, every turn. `sessionId` alone was 2,076 bytes of identical text across 16 tools.
|
|
158
|
+
const shared = SHARED_PARAM_SHORT[key];
|
|
159
|
+
if (shared !== undefined) {
|
|
160
|
+
// `.describe` returns the same schema with new prose, so optionality carries over. Re-wrapping
|
|
161
|
+
// in `.optional()` would nest one inside another and change the parameter's TYPE, which is a
|
|
162
|
+
// thing this lean path is meant to do for predicates and nothing else.
|
|
163
|
+
out[key] = schema.describe(shared);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
173
166
|
const desc = schema.description;
|
|
174
167
|
out[key] = 'string' === typeof desc ? schema.describe(firstSentence(desc)) : schema;
|
|
175
168
|
}
|
|
@@ -207,11 +200,24 @@ export function encodeResult(result, encoding) {
|
|
|
207
200
|
* "Call reticle_tools { names: [...] } for its parameters" — leaving it out made our own advice a
|
|
208
201
|
* dead end on the one surface that advertises everything.
|
|
209
202
|
*/
|
|
210
|
-
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) {
|
|
211
211
|
// The catalog reports which surface is live and what chose it — the only way an agent can see that
|
|
212
212
|
// a setting did not take (the daemon read the value it started with).
|
|
213
213
|
const origin = describeToolSurface(surface);
|
|
214
|
-
|
|
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)];
|
|
215
221
|
}
|
|
216
222
|
/**
|
|
217
223
|
* Exactly what gets advertised for one tool under a profile: trimmed description, lean input shape,
|
|
@@ -228,10 +234,25 @@ export function advertisedConfig(tool, advertised, profile) {
|
|
|
228
234
|
// sentence and the agent would have nothing left to learn the surface from. Measured when it
|
|
229
235
|
// happened: dynamic's tools/list fell 1,543 -> 849 bytes, which looks like a saving and is a
|
|
230
236
|
// capability loss.
|
|
231
|
-
|
|
237
|
+
//
|
|
238
|
+
// The two meta-tools are exempt, on every surface. Their descriptions are not a description OF a
|
|
239
|
+
// capability, they ARE the capability: the only place an agent is ever told that the tools it can
|
|
240
|
+
// see are not all the tools there are, and how to reach the rest. Trimmed to a first sentence,
|
|
241
|
+
// `reticle_tools` advertises as "Discover Reticle tools on demand." — which names no way to list
|
|
242
|
+
// anything, so a well-behaved agent has no reason to call it and the entire cold tail goes dark.
|
|
243
|
+
// The comment above already caught this for the retired `dynamic` profile; `default` is now both
|
|
244
|
+
// the lean surface and the one carrying the meta-tools, so the exemption follows it.
|
|
245
|
+
const isMetaTool = tool.name === ReticleTool.TOOLS || tool.name === ReticleTool.RUN;
|
|
246
|
+
// `verify` is lean for the same reason `default` is, and more so: it exists to make a verdict
|
|
247
|
+
// cheap, and it was serving FULL descriptions plus outputSchema because the check named only the
|
|
248
|
+
// default. That put 8,207 bytes on the wire for three tools.
|
|
249
|
+
const lean = profile === TOOL_SURFACE.DEFAULT || profile === TOOL_SURFACE.VERIFY;
|
|
250
|
+
const terse = lean && !isMetaTool;
|
|
232
251
|
// The first advertised tool carrying a predicate spells the grammar out; the rest point at it.
|
|
233
252
|
const anchor = advertised.find((t) => Object.values(t.inputSchema).some((schema) => isPredicateParam(schema)))?.name;
|
|
234
|
-
|
|
253
|
+
// Output schemas are dropped on every lean surface, meta-tools included: they are the single
|
|
254
|
+
// largest component and nothing on a lean surface validates against them.
|
|
255
|
+
const outputSchema = lean ? undefined : withSessionEnvelope(tool.name, tool.outputSchema);
|
|
235
256
|
return {
|
|
236
257
|
description: withExample(terse ? firstSentence(tool.description) : tool.description, tool.example),
|
|
237
258
|
inputSchema: terse
|
|
@@ -346,13 +367,33 @@ function toolNameOf(request) {
|
|
|
346
367
|
const name = params.name;
|
|
347
368
|
return 'string' === typeof name ? name : undefined;
|
|
348
369
|
}
|
|
349
|
-
export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT
|
|
370
|
+
export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT,
|
|
371
|
+
/**
|
|
372
|
+
* Has an app ever connected for this project? Decides what the instructions LEAD with.
|
|
373
|
+
*
|
|
374
|
+
* Passed in rather than read here, so construction stays free of disk I/O and the decision is
|
|
375
|
+
* testable without a state directory. Defaulting to `false` is the deliberate safe side: an agent
|
|
376
|
+
* told to instrument a project that is already wired loses a paragraph, while an agent not told
|
|
377
|
+
* loses the entire install — and the second is what the field overwhelmingly shows.
|
|
378
|
+
*/
|
|
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) {
|
|
350
389
|
const encoding = (process.env[ENCODING_ENV] ?? '').toLowerCase();
|
|
351
390
|
// Which surface this daemon advertises. The 18-tool default and the 48-tool full surface are
|
|
352
391
|
// different products from inside an agent's context, so outcomes are only comparable when the
|
|
353
392
|
// session says which one it saw.
|
|
354
393
|
getSessionMetrics().recordSurface(profile);
|
|
355
|
-
const server = new McpServer(SERVER_INFO, {
|
|
394
|
+
const server = new McpServer(SERVER_INFO, {
|
|
395
|
+
instructions: buildServerInstructions({ previouslyConnected }),
|
|
396
|
+
});
|
|
356
397
|
/**
|
|
357
398
|
* Record WHICH agent attached, at the handshake, for every session.
|
|
358
399
|
*
|
|
@@ -392,13 +433,13 @@ export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT) {
|
|
|
392
433
|
// 11 tools were unreachable with no escape hatch — including reticle_annotate, which
|
|
393
434
|
// reticle_flow_save's own description instructs the agent to call. `full` advertises everything
|
|
394
435
|
// directly and needs no hatch.
|
|
395
|
-
const advertised = advertisedTools(profile);
|
|
436
|
+
const advertised = advertisedTools(profile, tools);
|
|
396
437
|
installFriendlyArgErrors(server, new Map(advertised
|
|
397
438
|
.filter((tool) => tool.example !== undefined)
|
|
398
439
|
.map((tool) => [tool.name, JSON.stringify(tool.example)])),
|
|
399
|
-
// Every tool in
|
|
400
|
-
// so an unadvertised tool reached through the hatch must be just as strict.
|
|
401
|
-
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))])));
|
|
402
443
|
for (const tool of advertised) {
|
|
403
444
|
// Output schemas are now the largest slice of the per-request tax (55.6% of the hybrid payload
|
|
404
445
|
// after the predicate fix) and we are the only one of the three MCP servers that sends them.
|
|
@@ -460,6 +501,6 @@ export function createMcpServer(deps, profile = TOOL_SURFACE.DEFAULT) {
|
|
|
460
501
|
// `tools/call` dispatcher lazily on the FIRST registerTool, so wrapping before this point finds
|
|
461
502
|
// nothing to wrap and silently does nothing. Verified live rather than assumed — the first attempt
|
|
462
503
|
// sat here doing exactly that.
|
|
463
|
-
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)));
|
|
464
505
|
return server;
|
|
465
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
|
+
}
|
|
@@ -1,3 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The protocol's own way to say "the tool list you have is out of date, fetch it again".
|
|
3
|
+
*
|
|
4
|
+
* Load-bearing here rather than a nicety. When we answer `initialize` ourselves the catalog we serve
|
|
5
|
+
* is one we made up, and it is usually empty. Nothing else in the protocol ever corrects that: a
|
|
6
|
+
* client re-lists only when told to, and every other recovery path in the proxy is driven by the
|
|
7
|
+
* NEXT client request, which a client holding no tools never makes. Without this the session is
|
|
8
|
+
* connected, initialized and toolless until a human notices.
|
|
9
|
+
*/
|
|
10
|
+
export declare const TOOLS_CHANGED_NOTIFICATION = "notifications/tools/list_changed";
|
|
1
11
|
/** True when this queued client line is the handshake we answered ourselves. */
|
|
2
12
|
export declare function isHandshakeLine(line: string): boolean;
|
|
3
|
-
export declare function localInitializeResponse(line: string
|
|
13
|
+
export declare function localInitializeResponse(line: string,
|
|
14
|
+
/**
|
|
15
|
+
* What the daemon would have advertised, so the client is not left with none.
|
|
16
|
+
*
|
|
17
|
+
* A client reads `instructions` ONCE, at initialize, and this response is sent precisely when no
|
|
18
|
+
* daemon is up to send its own — which is the first run of a fresh install. So the block telling
|
|
19
|
+
* someone that having these tools is not the same as being set up was permanently absent for the
|
|
20
|
+
* exact population it addresses, and the daemon's later correct instructions arrive at a client
|
|
21
|
+
* that will never read the field again.
|
|
22
|
+
*
|
|
23
|
+
* Empty means "nothing to say", and the field is then omitted rather than sent blank.
|
|
24
|
+
*/
|
|
25
|
+
instructions?: string): string | null;
|
|
26
|
+
/**
|
|
27
|
+
* The line that tells a client its tool list is stale, or null when there is nothing to correct.
|
|
28
|
+
*
|
|
29
|
+
* When a daemon arrives after we answered the handshake ourselves, the client is holding a tool list
|
|
30
|
+
* we invented, and on a cold start that list is empty. Nothing else in this protocol corrects it: a
|
|
31
|
+
* client re-lists only when told to, and every other recovery path in the proxy is driven by the
|
|
32
|
+
* NEXT client request, which a client holding no tools never makes. Connected, initialized and
|
|
33
|
+
* toolless for the rest of the session, with a human required to notice and reconnect by hand.
|
|
34
|
+
*
|
|
35
|
+
* Sent only when the catalog was ours. A client that completed a real handshake with the daemon
|
|
36
|
+
* already has the true list, and telling it to refetch would be a round trip for nothing.
|
|
37
|
+
*
|
|
38
|
+
* Lives here rather than in the proxy because it belongs to the locally-answered handshake, which is
|
|
39
|
+
* this module's subject.
|
|
40
|
+
*
|
|
41
|
+
* Worth knowing before relying on it: this is a best-effort correction, not the mechanism. Research
|
|
42
|
+
* into the four clients we target found that only some honour `notifications/tools/list_changed` at
|
|
43
|
+
* all, so the catalog still has to be right in the FIRST answer for the rest. This closes the gap
|
|
44
|
+
* where it is honoured and costs one line where it is not.
|
|
45
|
+
*/
|
|
46
|
+
export declare function toolsChangedNotification(catalogWasLocal: boolean): string | null;
|
|
47
|
+
/**
|
|
48
|
+
* The largest single JSON-RPC line the proxy will accumulate from a client.
|
|
49
|
+
*
|
|
50
|
+
* Generous — a tool call with a big argument payload is legitimate — but finite, because without a
|
|
51
|
+
* ceiling the buffer grows to whatever is sent and the process is one `cat bigfile` away from an
|
|
52
|
+
* out-of-memory exit that would take the agent's whole tool surface with it.
|
|
53
|
+
*/
|
|
54
|
+
export declare const MAX_STDIN_LINE_BYTES: number;
|
|
55
|
+
export interface DrainedLines {
|
|
56
|
+
/** Complete lines, in order. Empty when the chunk carried no newline. */
|
|
57
|
+
lines: string[];
|
|
58
|
+
/** The trailing partial line, carried into the next chunk. */
|
|
59
|
+
rest: string;
|
|
60
|
+
/** A single line exceeded the cap and was discarded rather than accumulated. */
|
|
61
|
+
overflowed: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Mid-discard: an oversized line was dropped and the REST of it is still arriving.
|
|
64
|
+
*
|
|
65
|
+
* Carried back in on the next call, and it is what makes dropping safe rather than merely tidy.
|
|
66
|
+
* Without it the tail of the discarded line arrives looking like a complete line and is forwarded
|
|
67
|
+
* as a JSON-RPC message — a fragment of something nobody sent, failing to parse, on a link where
|
|
68
|
+
* the client is waiting for answers to real requests.
|
|
69
|
+
*/
|
|
70
|
+
discarding: boolean;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Append a chunk and split off whatever complete lines it produced.
|
|
74
|
+
*
|
|
75
|
+
* The reason this is not `(buffer + chunk).split('\n')` inline: that re-scans the WHOLE accumulated
|
|
76
|
+
* buffer on every chunk, so a single large line costs O(n²) in the size of the line. Measured
|
|
77
|
+
* against the real proxy, a 32 MB line took ~8s and a 50 MB line ~35s — with the event loop pinned
|
|
78
|
+
* throughout, so every other tool call on that link was frozen with no response and no error. The
|
|
79
|
+
* client sees the whole MCP surface hang.
|
|
80
|
+
*
|
|
81
|
+
* A chunk with no newline in it cannot complete a line, so there is nothing to split and the cost is
|
|
82
|
+
* the concatenation alone. Only a chunk that actually carries a newline pays for a scan.
|
|
83
|
+
*/
|
|
84
|
+
export declare function drainLines(buffer: string, chunk: string, maxLineBytes?: number, discarding?: boolean): DrainedLines;
|
|
@@ -14,10 +14,20 @@
|
|
|
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
|
+
/**
|
|
22
|
+
* The protocol's own way to say "the tool list you have is out of date, fetch it again".
|
|
23
|
+
*
|
|
24
|
+
* Load-bearing here rather than a nicety. When we answer `initialize` ourselves the catalog we serve
|
|
25
|
+
* is one we made up, and it is usually empty. Nothing else in the protocol ever corrects that: a
|
|
26
|
+
* client re-lists only when told to, and every other recovery path in the proxy is driven by the
|
|
27
|
+
* NEXT client request, which a client holding no tools never makes. Without this the session is
|
|
28
|
+
* connected, initialized and toolless until a human notices.
|
|
29
|
+
*/
|
|
30
|
+
export const TOOLS_CHANGED_NOTIFICATION = 'notifications/tools/list_changed';
|
|
21
31
|
function parseLine(line) {
|
|
22
32
|
try {
|
|
23
33
|
const parsed = JSON.parse(line);
|
|
@@ -32,7 +42,19 @@ export function isHandshakeLine(line) {
|
|
|
32
42
|
const msg = parseLine(line);
|
|
33
43
|
return 'initialize' === msg?.method || 'notifications/initialized' === msg?.method;
|
|
34
44
|
}
|
|
35
|
-
export function localInitializeResponse(line
|
|
45
|
+
export function localInitializeResponse(line,
|
|
46
|
+
/**
|
|
47
|
+
* What the daemon would have advertised, so the client is not left with none.
|
|
48
|
+
*
|
|
49
|
+
* A client reads `instructions` ONCE, at initialize, and this response is sent precisely when no
|
|
50
|
+
* daemon is up to send its own — which is the first run of a fresh install. So the block telling
|
|
51
|
+
* someone that having these tools is not the same as being set up was permanently absent for the
|
|
52
|
+
* exact population it addresses, and the daemon's later correct instructions arrive at a client
|
|
53
|
+
* that will never read the field again.
|
|
54
|
+
*
|
|
55
|
+
* Empty means "nothing to say", and the field is then omitted rather than sent blank.
|
|
56
|
+
*/
|
|
57
|
+
instructions = '') {
|
|
36
58
|
const msg = parseLine(line);
|
|
37
59
|
if (null === msg || msg.method !== 'initialize')
|
|
38
60
|
return null;
|
|
@@ -47,8 +69,93 @@ export function localInitializeResponse(line) {
|
|
|
47
69
|
// Echo what the client asked for: answering with a version it did not offer is its own
|
|
48
70
|
// handshake failure.
|
|
49
71
|
protocolVersion: 'string' === typeof proposed ? proposed : FALLBACK_PROTOCOL_VERSION,
|
|
50
|
-
|
|
72
|
+
// `listChanged` is not decoration: a client that was not told the list can change has no
|
|
73
|
+
// reason to honour the notification we send when the daemon finally arrives, and declaring a
|
|
74
|
+
// capability we then rely on is the difference between a fix and a message into the void.
|
|
75
|
+
capabilities: { tools: { listChanged: true } },
|
|
51
76
|
serverInfo: { name: MCP_SERVER_NAME, version: SERVER_VERSION },
|
|
77
|
+
...('' === instructions ? {} : { instructions }),
|
|
52
78
|
},
|
|
53
79
|
});
|
|
54
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* The line that tells a client its tool list is stale, or null when there is nothing to correct.
|
|
83
|
+
*
|
|
84
|
+
* When a daemon arrives after we answered the handshake ourselves, the client is holding a tool list
|
|
85
|
+
* we invented, and on a cold start that list is empty. Nothing else in this protocol corrects it: a
|
|
86
|
+
* client re-lists only when told to, and every other recovery path in the proxy is driven by the
|
|
87
|
+
* NEXT client request, which a client holding no tools never makes. Connected, initialized and
|
|
88
|
+
* toolless for the rest of the session, with a human required to notice and reconnect by hand.
|
|
89
|
+
*
|
|
90
|
+
* Sent only when the catalog was ours. A client that completed a real handshake with the daemon
|
|
91
|
+
* already has the true list, and telling it to refetch would be a round trip for nothing.
|
|
92
|
+
*
|
|
93
|
+
* Lives here rather than in the proxy because it belongs to the locally-answered handshake, which is
|
|
94
|
+
* this module's subject.
|
|
95
|
+
*
|
|
96
|
+
* Worth knowing before relying on it: this is a best-effort correction, not the mechanism. Research
|
|
97
|
+
* into the four clients we target found that only some honour `notifications/tools/list_changed` at
|
|
98
|
+
* all, so the catalog still has to be right in the FIRST answer for the rest. This closes the gap
|
|
99
|
+
* where it is honoured and costs one line where it is not.
|
|
100
|
+
*/
|
|
101
|
+
export function toolsChangedNotification(catalogWasLocal) {
|
|
102
|
+
if (!catalogWasLocal)
|
|
103
|
+
return null;
|
|
104
|
+
return JSON.stringify({ jsonrpc: '2.0', method: TOOLS_CHANGED_NOTIFICATION });
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The largest single JSON-RPC line the proxy will accumulate from a client.
|
|
108
|
+
*
|
|
109
|
+
* Generous — a tool call with a big argument payload is legitimate — but finite, because without a
|
|
110
|
+
* ceiling the buffer grows to whatever is sent and the process is one `cat bigfile` away from an
|
|
111
|
+
* out-of-memory exit that would take the agent's whole tool surface with it.
|
|
112
|
+
*/
|
|
113
|
+
export const MAX_STDIN_LINE_BYTES = 16 * 1024 * 1024;
|
|
114
|
+
/**
|
|
115
|
+
* Append a chunk and split off whatever complete lines it produced.
|
|
116
|
+
*
|
|
117
|
+
* The reason this is not `(buffer + chunk).split('\n')` inline: that re-scans the WHOLE accumulated
|
|
118
|
+
* buffer on every chunk, so a single large line costs O(n²) in the size of the line. Measured
|
|
119
|
+
* against the real proxy, a 32 MB line took ~8s and a 50 MB line ~35s — with the event loop pinned
|
|
120
|
+
* throughout, so every other tool call on that link was frozen with no response and no error. The
|
|
121
|
+
* client sees the whole MCP surface hang.
|
|
122
|
+
*
|
|
123
|
+
* A chunk with no newline in it cannot complete a line, so there is nothing to split and the cost is
|
|
124
|
+
* the concatenation alone. Only a chunk that actually carries a newline pays for a scan.
|
|
125
|
+
*/
|
|
126
|
+
export function drainLines(buffer, chunk, maxLineBytes = MAX_STDIN_LINE_BYTES, discarding = false) {
|
|
127
|
+
// Finish discarding first. Everything up to and including the next newline belongs to the line
|
|
128
|
+
// that was already dropped, and only what follows it is a message again.
|
|
129
|
+
if (discarding) {
|
|
130
|
+
const cut = chunk.indexOf('\n');
|
|
131
|
+
if (-1 === cut)
|
|
132
|
+
return { lines: [], rest: '', overflowed: true, discarding: true };
|
|
133
|
+
return {
|
|
134
|
+
...drainLines('', chunk.slice(cut + 1), maxLineBytes),
|
|
135
|
+
overflowed: true,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const next = buffer + chunk;
|
|
139
|
+
if (!chunk.includes('\n')) {
|
|
140
|
+
// Nothing can be complete yet. Cap here rather than on the split path: this is the branch an
|
|
141
|
+
// oversized line stays on, so it is the only one that can grow without bound. Dropping the
|
|
142
|
+
// buffer is half the job — the rest of that line is still coming, hence `discarding`.
|
|
143
|
+
if (next.length > maxLineBytes) {
|
|
144
|
+
return { lines: [], rest: '', overflowed: true, discarding: true };
|
|
145
|
+
}
|
|
146
|
+
return { lines: [], rest: next, overflowed: false, discarding: false };
|
|
147
|
+
}
|
|
148
|
+
const parts = next.split('\n');
|
|
149
|
+
const rest = parts.pop() ?? '';
|
|
150
|
+
// A completed line over the cap is dropped, not truncated: half a JSON-RPC message is not a
|
|
151
|
+
// message, and forwarding a prefix would be a parse error attributed to the client. Its newline
|
|
152
|
+
// has already arrived, so there is nothing left to swallow for it.
|
|
153
|
+
const oversizedLine = parts.some((line) => line.length > maxLineBytes);
|
|
154
|
+
const restOversized = rest.length > maxLineBytes;
|
|
155
|
+
return {
|
|
156
|
+
lines: oversizedLine ? parts.filter((line) => line.length <= maxLineBytes) : parts,
|
|
157
|
+
rest: restOversized ? '' : rest,
|
|
158
|
+
overflowed: oversizedLine || restOversized,
|
|
159
|
+
discarding: restOversized,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* reach it — and a guard that re-implements the decision instead of calling it is insensitive to the
|
|
18
18
|
* thing it claims to guard.
|
|
19
19
|
*/
|
|
20
|
+
import { PortPresence } from '../daemon/port-presence.js';
|
|
20
21
|
/** What to do when the SSE stream drops. */
|
|
21
22
|
export declare const OnDrop: {
|
|
22
23
|
/** A daemon is listening — reattach to it. */
|
|
@@ -28,8 +29,16 @@ export type OnDrop = (typeof OnDrop)[keyof typeof OnDrop];
|
|
|
28
29
|
/**
|
|
29
30
|
* A stream drop only ever justifies reattaching, never spawning. This is the whole fix: the proxy
|
|
30
31
|
* used to spawn here, which turned a daemon's own idle shutdown into a permanent respawn loop.
|
|
32
|
+
*
|
|
33
|
+
* It takes the PRESENCE, not a boolean. It used to take `daemonListening`, filled from a bare TCP
|
|
34
|
+
* connect — a probe that cannot tell a Reticle daemon from anything else that accepts a socket. A
|
|
35
|
+
* stranger on the bridge port therefore read as "a daemon is there", and since a stranger answers
|
|
36
|
+
* and closes, every reattach dropped and reattached again: measured at nineteen reconnects in
|
|
37
|
+
* forty-eight seconds, each logged as `reticle_mcp_proxy_reconnected`, forever. `probePresence`
|
|
38
|
+
* already asks the second question and every human-facing command already uses it; this was the last
|
|
39
|
+
* decision point still guessing from the first.
|
|
31
40
|
*/
|
|
32
|
-
export declare function onStreamDrop(
|
|
41
|
+
export declare function onStreamDrop(presence: PortPresence): OnDrop;
|
|
33
42
|
/**
|
|
34
43
|
* What to do when the reconnect budget runs out. Always DORMANT — never exit.
|
|
35
44
|
*
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* reach it — and a guard that re-implements the decision instead of calling it is insensitive to the
|
|
18
18
|
* thing it claims to guard.
|
|
19
19
|
*/
|
|
20
|
+
import { PortPresence } from '../daemon/port-presence.js';
|
|
20
21
|
/** What to do when the SSE stream drops. */
|
|
21
22
|
export const OnDrop = {
|
|
22
23
|
/** A daemon is listening — reattach to it. */
|
|
@@ -27,9 +28,17 @@ export const OnDrop = {
|
|
|
27
28
|
/**
|
|
28
29
|
* A stream drop only ever justifies reattaching, never spawning. This is the whole fix: the proxy
|
|
29
30
|
* used to spawn here, which turned a daemon's own idle shutdown into a permanent respawn loop.
|
|
31
|
+
*
|
|
32
|
+
* It takes the PRESENCE, not a boolean. It used to take `daemonListening`, filled from a bare TCP
|
|
33
|
+
* connect — a probe that cannot tell a Reticle daemon from anything else that accepts a socket. A
|
|
34
|
+
* stranger on the bridge port therefore read as "a daemon is there", and since a stranger answers
|
|
35
|
+
* and closes, every reattach dropped and reattached again: measured at nineteen reconnects in
|
|
36
|
+
* forty-eight seconds, each logged as `reticle_mcp_proxy_reconnected`, forever. `probePresence`
|
|
37
|
+
* already asks the second question and every human-facing command already uses it; this was the last
|
|
38
|
+
* decision point still guessing from the first.
|
|
30
39
|
*/
|
|
31
|
-
export function onStreamDrop(
|
|
32
|
-
return
|
|
40
|
+
export function onStreamDrop(presence) {
|
|
41
|
+
return PortPresence.DAEMON === presence ? OnDrop.REATTACH : OnDrop.DORMANT;
|
|
33
42
|
}
|
|
34
43
|
/**
|
|
35
44
|
* What to do when the reconnect budget runs out. Always DORMANT — never exit.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP proxy's own log file: where it lives, what bounds it, and how it is written.
|
|
3
|
+
*
|
|
4
|
+
* Split out of mcp-proxy.ts, which is the transport. This is housekeeping for a file, and the two
|
|
5
|
+
* share nothing but a port number — the proxy calls `proxyLog`, and everything else here exists to
|
|
6
|
+
* keep that file from becoming the largest thing on the user's disk.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* How much the proxy may append between size checks.
|
|
10
|
+
*
|
|
11
|
+
* Statting the file on every line would be a syscall per log line on a process that logs from a
|
|
12
|
+
* reconnect loop. Counting what we wrote costs nothing and is exact enough, because this process is
|
|
13
|
+
* the only writer of its own log. Well under the cap, so the cap is a cap rather than a suggestion.
|
|
14
|
+
*/
|
|
15
|
+
export declare const PROXY_LOG_CHECK_BYTES: number;
|
|
16
|
+
/**
|
|
17
|
+
* Keep the proxy log under the same cap as the daemon log, without a stat per write.
|
|
18
|
+
*
|
|
19
|
+
* Returns the new "bytes since the last check" counter, so the accounting is testable without a
|
|
20
|
+
* filesystem. Rotation itself is `rotateDaemonLog` — the proxy log had no cap at all while the
|
|
21
|
+
* daemon log on the SAME machine stayed small, which is the whole of this defect.
|
|
22
|
+
*/
|
|
23
|
+
export declare function accountProxyLogWrite(bytesSinceCheck: number, written: number, path: string, deps: {
|
|
24
|
+
fileSize(p: string): number;
|
|
25
|
+
renameFile(from: string, to: string): void;
|
|
26
|
+
}): number;
|
|
27
|
+
/**
|
|
28
|
+
* Reclaim a proxy log that a previous process already let run away. Returns the bytes reclaimed.
|
|
29
|
+
*
|
|
30
|
+
* Reported from the field: one machine's `proxy-4400.log` reached a third of a 460GB disk and broke
|
|
31
|
+
* builds, Docker and ordinary shell commands with ENOSPC. Nothing in Reticle degraded first, so the
|
|
32
|
+
* failure surfaced as the operating system refusing to write files. Files like that exist on real
|
|
33
|
+
* machines now, and the cap alone does not help them: the user should not have to find one with `du`.
|
|
34
|
+
*
|
|
35
|
+
* TRUNCATED IN PLACE, never renamed or unlinked. A rename moves the bytes without reclaiming a byte,
|
|
36
|
+
* and a file that a running process still holds open keeps its blocks allocated after an unlink
|
|
37
|
+
* until that handle closes — which is why the reporter recovered with `: > ~/.reticle/proxy-4400.log`
|
|
38
|
+
* and why that is the operation to copy here.
|
|
39
|
+
*
|
|
40
|
+
* Best-effort, in the same spirit as `rotateDaemonLog`: refusing to start the MCP server over
|
|
41
|
+
* housekeeping is strictly worse than a large file.
|
|
42
|
+
*/
|
|
43
|
+
export declare function recoverOversizedProxyLog(path: string, deps: {
|
|
44
|
+
fileSize(p: string): number;
|
|
45
|
+
truncateFile(p: string): void;
|
|
46
|
+
}, max?: number): number;
|
|
47
|
+
/** Name the proxy log after the port it serves. Called before anything can log. */
|
|
48
|
+
export declare function setProxyLogPort(port: number): void;
|
|
49
|
+
/** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
|
|
50
|
+
export declare function proxyLogPath(port?: number | undefined): string;
|
|
51
|
+
/**
|
|
52
|
+
* Log to stderr (which the agent host usually swallows) AND to ~/.reticle/proxy-<port>.log, which it
|
|
53
|
+
* does not. A dropped MCP connection is invisible from the agent's side — no message, no exit code —
|
|
54
|
+
* so the one thing that makes it diagnosable at all is a file somebody can read afterwards.
|
|
55
|
+
*
|
|
56
|
+
* EXPORTED because the crash handlers need it. `installProxyResilience` was wired to the bare stderr
|
|
57
|
+
* logger, so the proxy's own uncaught exceptions and unhandled rejections — the exact events that
|
|
58
|
+
* present to a human as "the MCP server disconnected" — were written to a stream the editor throws
|
|
59
|
+
* away. The handler ran, the process kept serving, and the reason went nowhere. Reported as "the
|
|
60
|
+
* proxy has no log file… when it dies the diagnostic dies with it", which was half right: the file
|
|
61
|
+
* existed, and the one path that most needed it was not using it.
|
|
62
|
+
*/
|
|
63
|
+
export declare function proxyLog(event: string, fields?: Record<string, unknown>): void;
|