@rubytech/create-realagent 1.0.867 → 1.0.869

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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/graph-mcp/dist/__tests__/warnings-envelope.test.d.ts +2 -0
  3. package/payload/platform/lib/graph-mcp/dist/__tests__/warnings-envelope.test.d.ts.map +1 -0
  4. package/payload/platform/lib/graph-mcp/dist/__tests__/warnings-envelope.test.js +140 -0
  5. package/payload/platform/lib/graph-mcp/dist/__tests__/warnings-envelope.test.js.map +1 -0
  6. package/payload/platform/lib/graph-mcp/dist/cypher-shim-read.d.ts +85 -0
  7. package/payload/platform/lib/graph-mcp/dist/cypher-shim-read.d.ts.map +1 -0
  8. package/payload/platform/lib/graph-mcp/dist/cypher-shim-read.js +93 -0
  9. package/payload/platform/lib/graph-mcp/dist/cypher-shim-read.js.map +1 -0
  10. package/payload/platform/lib/graph-mcp/dist/index.js +82 -11
  11. package/payload/platform/lib/graph-mcp/dist/index.js.map +1 -1
  12. package/payload/platform/lib/graph-mcp/src/__tests__/warnings-envelope.test.ts +151 -0
  13. package/payload/platform/lib/graph-mcp/src/cypher-shim-read.ts +141 -0
  14. package/payload/platform/lib/graph-mcp/src/index.ts +107 -11
  15. package/payload/platform/plugins/admin/PLUGIN.md +2 -1
  16. package/payload/platform/plugins/admin/mcp/dist/__tests__/public-hostname.test.d.ts +2 -0
  17. package/payload/platform/plugins/admin/mcp/dist/__tests__/public-hostname.test.d.ts.map +1 -0
  18. package/payload/platform/plugins/admin/mcp/dist/__tests__/public-hostname.test.js +106 -0
  19. package/payload/platform/plugins/admin/mcp/dist/__tests__/public-hostname.test.js.map +1 -0
  20. package/payload/platform/plugins/admin/mcp/dist/index.js +34 -0
  21. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  22. package/payload/platform/plugins/admin/mcp/dist/lib/public-hostname.d.ts +21 -0
  23. package/payload/platform/plugins/admin/mcp/dist/lib/public-hostname.d.ts.map +1 -0
  24. package/payload/platform/plugins/admin/mcp/dist/lib/public-hostname.js +54 -0
  25. package/payload/platform/plugins/admin/mcp/dist/lib/public-hostname.js.map +1 -0
  26. package/payload/platform/plugins/admin/skills/publish-site/SKILL.md +2 -3
  27. package/payload/platform/plugins/admin/skills/unzip-attachment/SKILL.md +20 -7
  28. package/payload/platform/plugins/admin/skills/unzip-attachment/__tests__/preflight.sh +148 -0
  29. package/payload/platform/plugins/admin/skills/unzip-attachment/references/safety.md +53 -18
  30. package/payload/platform/plugins/docs/references/internals.md +2 -0
  31. package/payload/platform/templates/specialists/agents/content-producer.md +1 -1
  32. package/payload/server/chunk-B5VSPQQP.js +11320 -0
  33. package/payload/server/maxy-edge.js +1 -1
  34. package/payload/server/server.js +1 -1
@@ -0,0 +1,151 @@
1
+ // Task 970 — Neo4j envelope-warning passthrough.
2
+ //
3
+ // Pure-function tests for the helpers in cypher-shim-read.ts:
4
+ // - filterEnvelopeNotifications: keeps only ^0[12]N5\d$ GQL status codes
5
+ // - stitchWarningsIntoResponse: prepends a warnings content block
6
+ // - runReadProbe: runs cypher through a shim-side session
7
+ // and returns notifications from the summary
8
+ //
9
+ // Failure mode this closes: the agent ran `RETURN h.hostname` against a node
10
+ // whose property is `hostnameValue`, Neo4j emitted gql_status=01N52
11
+ // "property does not exist", but the warning was dropped by the upstream
12
+ // envelope and the agent saw `[]`. Without these warnings reaching the tool
13
+ // result, the same recurrence will hit other property names too.
14
+ import test from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import {
17
+ filterEnvelopeNotifications,
18
+ stitchWarningsIntoResponse,
19
+ runReadProbe,
20
+ type DriverNotification,
21
+ } from "../cypher-shim-read.js";
22
+
23
+ test("filterEnvelopeNotifications keeps 01N5x and 02N5x, drops everything else", () => {
24
+ const input: DriverNotification[] = [
25
+ { gqlStatus: "01N52", title: "...", description: "property does not exist (foo)", position: { offset: 0, line: 1, column: 1 } },
26
+ { gqlStatus: "02N50", title: "...", description: "label does not exist (Foo)", position: null },
27
+ { gqlStatus: "01N40", title: "...", description: "wrong family", position: null },
28
+ { gqlStatus: "03N42", title: "...", description: "unrelated", position: null },
29
+ { gqlStatus: "00N00", title: "...", description: "neutral status", position: null },
30
+ ];
31
+ const filtered = filterEnvelopeNotifications(input);
32
+ assert.equal(filtered.length, 2);
33
+ assert.equal(filtered[0].gql_status, "01N52");
34
+ assert.equal(filtered[0].description, "property does not exist (foo)");
35
+ assert.deepEqual(filtered[0].position, { offset: 0, line: 1, column: 1 });
36
+ assert.equal(filtered[1].gql_status, "02N50");
37
+ assert.equal(filtered[1].position, null);
38
+ });
39
+
40
+ test("filterEnvelopeNotifications on an empty or all-irrelevant list returns []", () => {
41
+ assert.deepEqual(filterEnvelopeNotifications([]), []);
42
+ assert.deepEqual(
43
+ filterEnvelopeNotifications([
44
+ { gqlStatus: "03N42", title: "", description: "", position: null },
45
+ { gqlStatus: "01N40", title: "", description: "", position: null },
46
+ ]),
47
+ [],
48
+ );
49
+ });
50
+
51
+ test("stitchWarningsIntoResponse prepends a warnings content block when warnings exist", () => {
52
+ const original = {
53
+ jsonrpc: "2.0",
54
+ id: 42,
55
+ result: { content: [{ type: "text", text: "[]" }] },
56
+ };
57
+ const warnings = [
58
+ { gql_status: "01N52", description: "property hostname does not exist", position: null },
59
+ ];
60
+ const out = stitchWarningsIntoResponse(original, warnings);
61
+ assert.notEqual(out, null);
62
+ const parsed = JSON.parse(out!);
63
+ assert.equal(parsed.id, 42);
64
+ assert.equal(parsed.result.content.length, 2);
65
+ assert.equal(parsed.result.content[0].type, "text");
66
+ assert.match(parsed.result.content[0].text, /Neo4j envelope warnings/);
67
+ assert.match(parsed.result.content[0].text, /01N52/);
68
+ assert.match(parsed.result.content[0].text, /property hostname does not exist/);
69
+ // Original row block preserved verbatim, after the warnings prefix.
70
+ assert.equal(parsed.result.content[1].text, "[]");
71
+ });
72
+
73
+ test("stitchWarningsIntoResponse returns null when no warnings (no envelope rewrite)", () => {
74
+ const original = { jsonrpc: "2.0", id: 1, result: { content: [{ type: "text", text: "[]" }] } };
75
+ assert.equal(stitchWarningsIntoResponse(original, []), null);
76
+ });
77
+
78
+ test("stitchWarningsIntoResponse handles missing result.content defensively", () => {
79
+ const original = { jsonrpc: "2.0", id: 1, result: {} };
80
+ const warnings = [{ gql_status: "01N52", description: "x", position: null }];
81
+ const out = stitchWarningsIntoResponse(original as never, warnings);
82
+ assert.notEqual(out, null);
83
+ const parsed = JSON.parse(out!);
84
+ assert.equal(parsed.result.content.length, 1);
85
+ assert.match(parsed.result.content[0].text, /01N52/);
86
+ });
87
+
88
+ test("runReadProbe returns notifications captured from the driver summary", async () => {
89
+ const session = {
90
+ run: async () => ({
91
+ summary: {
92
+ notifications: [
93
+ { gqlStatus: "01N52", title: "", description: "missing prop", position: null },
94
+ ] as DriverNotification[],
95
+ },
96
+ }),
97
+ close: async () => {},
98
+ };
99
+ const driver = { session: () => session };
100
+ const notifs = await runReadProbe(driver, "MATCH (n) RETURN n.foo", {});
101
+ assert.equal(notifs.length, 1);
102
+ assert.equal(notifs[0].gqlStatus, "01N52");
103
+ });
104
+
105
+ test("runReadProbe returns [] when driver summary has no notifications", async () => {
106
+ const session = {
107
+ run: async () => ({ summary: {} }),
108
+ close: async () => {},
109
+ };
110
+ const driver = { session: () => session };
111
+ assert.deepEqual(await runReadProbe(driver, "RETURN 1", {}), []);
112
+ });
113
+
114
+ test("runReadProbe closes the session even when run() throws", async () => {
115
+ let closed = false;
116
+ const session = {
117
+ run: async () => {
118
+ throw new Error("boom");
119
+ },
120
+ close: async () => {
121
+ closed = true;
122
+ },
123
+ };
124
+ const driver = { session: () => session };
125
+ await assert.rejects(() => runReadProbe(driver, "RETURN 1", {}));
126
+ assert.equal(closed, true, "session must be closed on error");
127
+ });
128
+
129
+ test("end-to-end: filter + stitch on a real-shape upstream response surfaces 01N52", () => {
130
+ // Models the failure mode in the original log: upstream returned an empty
131
+ // row list; Neo4j attached an 01N52 notification that the upstream Python
132
+ // server dropped.
133
+ const upstreamResponse = {
134
+ jsonrpc: "2.0",
135
+ id: 99,
136
+ result: { content: [{ type: "text", text: "[]" }] },
137
+ };
138
+ const driverNotifications: DriverNotification[] = [
139
+ {
140
+ gqlStatus: "01N52",
141
+ title: "Property does not exist",
142
+ description: "The property 'hostname' does not exist on the node",
143
+ position: { offset: 38, line: 1, column: 39 },
144
+ },
145
+ ];
146
+ const warnings = filterEnvelopeNotifications(driverNotifications);
147
+ const stitched = stitchWarningsIntoResponse(upstreamResponse, warnings);
148
+ const parsed = JSON.parse(stitched!);
149
+ assert.match(parsed.result.content[0].text, /01N52/);
150
+ assert.match(parsed.result.content[0].text, /hostname/);
151
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Pure helpers for the shim-side read-warning probe (Task 970).
3
+ *
4
+ * The upstream `mcp-neo4j-cypher@0.6.0` server runs the cypher against Neo4j
5
+ * itself and returns rendered text; it drops `result.summary.notifications`
6
+ * before serialising the JSON-RPC response. That is the dropped-envelope leg
7
+ * of failure mode `676504f1`: the agent ran `RETURN h.hostname` against a
8
+ * node whose property is `hostnameValue`, Neo4j emitted `gql_status=01N52
9
+ * "property does not exist"`, the upstream surfaced it to its stderr but
10
+ * NOT to the tool result, and the agent saw `[]` with no actionable signal.
11
+ *
12
+ * The shim now runs the same cypher in a second, lightweight pass against
13
+ * its own Neo4j driver — only to read `summary.notifications` and stitch
14
+ * codes matching `^0[12]N5\d$` into a warnings prefix block on the
15
+ * upstream's response. The probe is sequential (after the upstream has
16
+ * returned) so concurrent ordering and error-handling stay simple. The
17
+ * upstream's rendered row text is left byte-for-byte untouched as
18
+ * `content[1]`, so existing callers that read the rows do not see a format
19
+ * change.
20
+ *
21
+ * Extracted from index.ts so the filter/stitch logic is testable as pure
22
+ * functions without booting the shim's stdin/stdout pipe.
23
+ */
24
+
25
+ /** GQL status codes Neo4j 5.x emits for missing / unrecognised schema tokens.
26
+ * Covers 01N5x ("property does not exist", "type does not exist", etc.) and
27
+ * 02N5x (label / type missing in pattern). These are the codes whose loss
28
+ * most often manifests as an empty-rows result the agent cannot diagnose. */
29
+ const ENVELOPE_GQL_RE = /^0[12]N5\d$/;
30
+
31
+ /** Shape of a notification as returned by neo4j-driver 5.x's `summary.notifications`. */
32
+ export interface DriverNotification {
33
+ gqlStatus: string;
34
+ title: string;
35
+ description: string;
36
+ position: { offset: number; line: number; column: number } | null;
37
+ }
38
+
39
+ /** Stitched envelope warning shape — what the agent sees in the tool result. */
40
+ export interface EnvelopeWarning {
41
+ gql_status: string;
42
+ description: string;
43
+ position: { offset: number; line: number; column: number } | null;
44
+ }
45
+
46
+ /** Keep only notifications whose gql_status matches the envelope-passthrough set. */
47
+ export function filterEnvelopeNotifications(
48
+ notifications: DriverNotification[],
49
+ ): EnvelopeWarning[] {
50
+ const out: EnvelopeWarning[] = [];
51
+ for (const n of notifications) {
52
+ if (typeof n.gqlStatus === "string" && ENVELOPE_GQL_RE.test(n.gqlStatus)) {
53
+ out.push({
54
+ gql_status: n.gqlStatus,
55
+ description: n.description,
56
+ position: n.position ?? null,
57
+ });
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** JSON-RPC response shape — just enough to clone + prepend a content block. */
64
+ export interface ResponseEnvelope {
65
+ jsonrpc?: string;
66
+ id?: string | number;
67
+ result?: {
68
+ content?: Array<{ type?: string; text?: string }>;
69
+ isError?: boolean;
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Prepend a warnings content block to a JSON-RPC read response.
75
+ * Returns the rewritten JSON string, or `null` if there are no warnings to
76
+ * surface (caller forwards the original line unchanged in that case).
77
+ */
78
+ export function stitchWarningsIntoResponse(
79
+ msg: ResponseEnvelope,
80
+ warnings: EnvelopeWarning[],
81
+ ): string | null {
82
+ if (warnings.length === 0) return null;
83
+ const lines = warnings.map(
84
+ (w) =>
85
+ ` - gql_status=${w.gql_status} ${w.description}` +
86
+ (w.position
87
+ ? ` (line ${w.position.line}, column ${w.position.column})`
88
+ : ""),
89
+ );
90
+ const text =
91
+ "Neo4j envelope warnings — these were emitted by the driver but " +
92
+ "dropped by the upstream server. Treat them as schema feedback on " +
93
+ "the cypher you just ran:\n" +
94
+ lines.join("\n") +
95
+ "\n\n--- results below ---";
96
+ const original = msg.result?.content ?? [];
97
+ const wrapped: ResponseEnvelope = {
98
+ ...msg,
99
+ result: {
100
+ ...(msg.result ?? {}),
101
+ content: [{ type: "text", text }, ...original],
102
+ },
103
+ };
104
+ return JSON.stringify(wrapped);
105
+ }
106
+
107
+ /** Minimal session / driver interfaces for the probe — defined here so the
108
+ * probe is testable without importing `neo4j-driver` types at test time. */
109
+ export interface ProbeSession {
110
+ run(
111
+ cypher: string,
112
+ params?: Record<string, unknown>,
113
+ ): Promise<{ summary: { notifications?: DriverNotification[] } }>;
114
+ close(): Promise<void>;
115
+ }
116
+
117
+ export interface ProbeDriver {
118
+ session(): ProbeSession;
119
+ }
120
+
121
+ /**
122
+ * Run cypher through the shim's driver purely to capture
123
+ * `result.summary.notifications`. The probe is best-effort: any error closes
124
+ * the session and rethrows so the caller can decide to forward the
125
+ * upstream's response unchanged.
126
+ */
127
+ export async function runReadProbe(
128
+ driver: ProbeDriver,
129
+ cypher: string,
130
+ params: Record<string, unknown>,
131
+ ): Promise<DriverNotification[]> {
132
+ const session = driver.session();
133
+ try {
134
+ const result = await session.run(cypher, params);
135
+ return result.summary.notifications ?? [];
136
+ } finally {
137
+ await session.close().catch(() => {
138
+ /* session already closed on error path — swallow */
139
+ });
140
+ }
141
+ }
@@ -48,6 +48,13 @@ import {
48
48
  synthesiseWriteResponse,
49
49
  type GraphDriver,
50
50
  } from "./cypher-shim-write.js";
51
+ import {
52
+ filterEnvelopeNotifications,
53
+ runReadProbe,
54
+ stitchWarningsIntoResponse,
55
+ type ProbeDriver,
56
+ } from "./cypher-shim-read.js";
57
+ import { createHash } from "node:crypto";
51
58
 
52
59
  const SERVER_NAME = "graph";
53
60
  const UPSTREAM_PACKAGE = "mcp-neo4j-cypher@0.6.0";
@@ -301,6 +308,10 @@ interface PendingCall {
301
308
  * upstream commits (the validator only detected them; emission waits for
302
309
  * the post-write line family). */
303
310
  writeUnknownTokens: UnknownToken[];
311
+ /** Task 970: full operator-supplied params (e.g. for $-bound names), kept so
312
+ * the post-response envelope-warning probe runs the same cypher with the
313
+ * same params as the upstream call. Reads only. */
314
+ cypherParams: Record<string, unknown> | null;
304
315
  }
305
316
  const pending = new Map<string | number, PendingCall>();
306
317
 
@@ -641,6 +652,13 @@ function handleRequestLine(line: string): RequestDecision {
641
652
  ? extractSessionIdParam(msg.params?.arguments)
642
653
  : null;
643
654
 
655
+ const operatorArgs = msg.params?.arguments ?? {};
656
+ const paramsArg = operatorArgs["params"];
657
+ const cypherParams: Record<string, unknown> | null =
658
+ paramsArg && typeof paramsArg === "object" && !Array.isArray(paramsArg)
659
+ ? (paramsArg as Record<string, unknown>)
660
+ : null;
661
+
644
662
  const entry: PendingCall = {
645
663
  method: methodName,
646
664
  cypherPrefix,
@@ -651,6 +669,7 @@ function handleRequestLine(line: string): RequestDecision {
651
669
  validated: false,
652
670
  readWarnings: [],
653
671
  writeUnknownTokens: [],
672
+ cypherParams: isWriteCall ? null : cypherParams,
654
673
  };
655
674
 
656
675
  if (!isCypherCall || !cypherFull) {
@@ -759,7 +778,7 @@ function handleRequestLine(line: string): RequestDecision {
759
778
  return "forward";
760
779
  }
761
780
 
762
- function handleResponseLine(line: string): string | null {
781
+ async function handleResponseLine(line: string): Promise<string | null> {
763
782
  let msg: JsonRpcMessage;
764
783
  try {
765
784
  msg = JSON.parse(line) as JsonRpcMessage;
@@ -853,18 +872,83 @@ function handleResponseLine(line: string): string | null {
853
872
  }
854
873
  }
855
874
 
875
+ // Task 970 — envelope-warning probe. Reads only, after upstream succeeds.
876
+ // Runs the same cypher through the shim's own driver to harvest
877
+ // `summary.notifications` matching ^0[12]N5\d$ (property/label-missing
878
+ // codes the upstream Python server drops before serialising). Best-effort:
879
+ // any probe failure leaves the upstream response untouched.
880
+ let envelopeStitched: string | null = null;
881
+ if (
882
+ !p.isWrite &&
883
+ p.cypherFull &&
884
+ msg.result &&
885
+ !msg.result.isError &&
886
+ p.method === READ_CYPHER_TOOL
887
+ ) {
888
+ try {
889
+ const driver = (await getSharedDriver(
890
+ resolvedNeo4jUri,
891
+ neo4jUser,
892
+ neo4jPassword,
893
+ )) as ProbeDriver;
894
+ const notifications = await runReadProbe(
895
+ driver,
896
+ p.cypherFull,
897
+ p.cypherParams ?? {},
898
+ );
899
+ const envelopeWarnings = filterEnvelopeNotifications(notifications);
900
+ if (envelopeWarnings.length > 0) {
901
+ const queryHash = createHash("sha1")
902
+ .update(p.cypherFull)
903
+ .digest("hex")
904
+ .slice(0, 12);
905
+ for (const w of envelopeWarnings) {
906
+ console.error(
907
+ `[mcp:graph] envelope-warning gql_status=${w.gql_status} query_hash=${queryHash} description="${w.description.replace(/"/g, "'")}"`,
908
+ );
909
+ }
910
+ envelopeStitched = stitchWarningsIntoResponse(msg, envelopeWarnings);
911
+ }
912
+ } catch (err) {
913
+ const errMsg = err instanceof Error ? err.message : String(err);
914
+ console.error(
915
+ `[mcp:graph] probe-error op=${p.method} error="${errMsg.replace(/"/g, "'")}" — forwarding response without envelope stitch`,
916
+ );
917
+ }
918
+ }
919
+
920
+ // Compose with the existing cypher-validate warning prefix (Task 654). If
921
+ // both are present, the envelope warnings appear first (outermost prepend),
922
+ // then the validation warnings, then the upstream's rendered rows.
856
923
  if (p.readWarnings.length > 0) {
857
924
  try {
858
- return wrapReadWarnings(msg, p.readWarnings);
925
+ const validationWrapped = wrapReadWarnings(msg, p.readWarnings);
926
+ if (envelopeStitched) {
927
+ // Re-stitch envelope warnings ONTO the validation-wrapped message.
928
+ const parsed = JSON.parse(envelopeStitched) as JsonRpcMessage;
929
+ const valParsed = JSON.parse(validationWrapped) as JsonRpcMessage;
930
+ const merged: JsonRpcMessage = {
931
+ ...valParsed,
932
+ result: {
933
+ ...(valParsed.result ?? {}),
934
+ content: [
935
+ ...(parsed.result?.content ?? []).slice(0, 1),
936
+ ...(valParsed.result?.content ?? []),
937
+ ],
938
+ },
939
+ };
940
+ return JSON.stringify(merged);
941
+ }
942
+ return validationWrapped;
859
943
  } catch (err) {
860
944
  const errMsg = err instanceof Error ? err.message : String(err);
861
945
  console.error(
862
946
  `[cypher-validate] warning-wrap failed op=${p.method} error="${errMsg.replace(/"/g, "'")}" — forwarding response unwrapped`,
863
947
  );
864
- return null;
948
+ return envelopeStitched;
865
949
  }
866
950
  }
867
- return null;
951
+ return envelopeStitched;
868
952
  }
869
953
 
870
954
  /**
@@ -911,18 +995,30 @@ process.stdin.on("end", () => {
911
995
  });
912
996
 
913
997
  const responseBuffer = makeLineBuffer();
998
+ // Task 970 — handleResponseLine is now async (envelope-warning probe). The
999
+ // per-line work is serialised through a write-chain so multi-line chunks
1000
+ // preserve their original byte order on the way to stdout. The chain only
1001
+ // adds latency when the probe actually runs (reads with a result); other
1002
+ // lines resolve synchronously and append immediately.
1003
+ let responseWriteChain: Promise<void> = Promise.resolve();
914
1004
  child.stdout.on("data", (chunk: Buffer) => {
915
1005
  for (const line of responseBuffer.push(chunk)) {
916
- if (line.length === 0) {
917
- process.stdout.write("\n");
918
- continue;
919
- }
920
- const rewritten = handleResponseLine(line);
921
- process.stdout.write(`${rewritten ?? line}\n`);
1006
+ responseWriteChain = responseWriteChain.then(async () => {
1007
+ if (line.length === 0) {
1008
+ process.stdout.write("\n");
1009
+ return;
1010
+ }
1011
+ const rewritten = await handleResponseLine(line);
1012
+ process.stdout.write(`${rewritten ?? line}\n`);
1013
+ });
922
1014
  }
923
1015
  });
924
1016
  child.stdout.on("end", () => {
925
- process.stdout.end();
1017
+ // Task 970 — handleResponseLine is async; if a probe is still in flight when
1018
+ // upstream closes its stdout, ending process.stdout synchronously would drop
1019
+ // the final response (Node's Writable silently discards writes after end()).
1020
+ // Await the write-chain so every queued line reaches stdout first.
1021
+ responseWriteChain.then(() => process.stdout.end());
926
1022
  });
927
1023
 
928
1024
  for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
@@ -1,8 +1,9 @@
1
1
  ---
2
2
  name: admin
3
- description: "Platform administration plugin. Provides system-status, brand-settings, account-manage, account-update, admin-add, admin-remove, admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, plugin-read, store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), render-component, session-reset, session-resume, file-attach, wifi, adherence-read (attention-weighted adherence ledger), and action-approval tools (action-pending, action-approve, action-reject, action-edit) for managing the Maxy platform."
3
+ description: "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver — single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), brand-settings, account-manage, account-update, admin-add, admin-remove, admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, plugin-read, store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), render-component, session-reset, session-resume, file-attach, wifi, adherence-read (attention-weighted adherence ledger), and action-approval tools (action-pending, action-approve, action-reject, action-edit) for managing the Maxy platform."
4
4
  tools:
5
5
  - system-status
6
+ - public-hostname
6
7
  - brand-settings
7
8
  - account-manage
8
9
  - account-update
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=public-hostname.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"public-hostname.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/public-hostname.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,106 @@
1
+ // Task 970 — public-hostname deterministic resolver.
2
+ //
3
+ // Locks in the contract for `resolvePublicHostname` (the helper backing the
4
+ // new `public-hostname` MCP tool). The tool exists so an agent following
5
+ // publish-site never has to guess the property name on `CloudflareHostname`
6
+ // nodes (the Task 970 root cause: `RETURN h.hostname` instead of
7
+ // `h.hostnameValue`, returning `[]` and exhausting the turn budget).
8
+ //
9
+ // Tests stub a session-shaped object and assert exact cypher text, params,
10
+ // and return shape — no driver, no neo4j-driver dependency at test time.
11
+ import { describe, it, expect, vi } from "vitest";
12
+ import { resolvePublicHostname, } from "../lib/public-hostname.js";
13
+ function assertMiss(r) {
14
+ if (r.hostname !== null)
15
+ throw new Error("expected miss, got hit");
16
+ }
17
+ function record(fields) {
18
+ return { get: (k) => fields[k] };
19
+ }
20
+ function mockSession(results) {
21
+ let i = 0;
22
+ const run = vi.fn((_cypher, _params) => {
23
+ if (i >= results.length)
24
+ throw new Error(`unexpected session.run call #${i + 1}`);
25
+ return Promise.resolve(results[i++]);
26
+ });
27
+ const close = vi.fn(() => Promise.resolve());
28
+ return { run, close };
29
+ }
30
+ describe("resolvePublicHostname", () => {
31
+ it("returns hostname/isApex/tunnelId on hit, after one cypher call against CloudflareHostname", async () => {
32
+ const session = mockSession([
33
+ {
34
+ records: [
35
+ record({ hostname: "test.maxy.bot", isApex: false, tunnelId: "tun-abc" }),
36
+ ],
37
+ },
38
+ ]);
39
+ const r = await resolvePublicHostname(session, "acc-1");
40
+ expect(r).toEqual({
41
+ hostname: "test.maxy.bot",
42
+ isApex: false,
43
+ tunnelId: "tun-abc",
44
+ });
45
+ expect(session.run).toHaveBeenCalledTimes(1);
46
+ const [cypher, params] = session.run.mock.calls[0];
47
+ expect(cypher).toContain("CloudflareHostname");
48
+ expect(cypher).toContain("h.hostnameValue");
49
+ expect(cypher).toContain("h.isApex");
50
+ expect(cypher).toContain("h.tunnelId");
51
+ expect(cypher).toContain("ORDER BY h.isApex DESC");
52
+ expect(cypher).toContain("LIMIT 1");
53
+ expect(params).toEqual({ accountId: "acc-1" });
54
+ });
55
+ it("returns reason=no-hostname when hostname missing but a tunnel exists", async () => {
56
+ const session = mockSession([
57
+ { records: [] },
58
+ { records: [record({ n: 1 })] },
59
+ ]);
60
+ const r = await resolvePublicHostname(session, "acc-1");
61
+ expect(r).toEqual({
62
+ hostname: null,
63
+ isApex: null,
64
+ tunnelId: null,
65
+ reason: "no-hostname",
66
+ });
67
+ expect(session.run).toHaveBeenCalledTimes(2);
68
+ const [tunnelCypher] = session.run.mock.calls[1];
69
+ expect(tunnelCypher).toContain("CloudflareTunnel");
70
+ });
71
+ it("returns reason=no-tunnel when neither hostname nor tunnel exists", async () => {
72
+ const session = mockSession([
73
+ { records: [] },
74
+ { records: [record({ n: 0 })] },
75
+ ]);
76
+ const r = await resolvePublicHostname(session, "acc-1");
77
+ expect(r).toEqual({
78
+ hostname: null,
79
+ isApex: null,
80
+ tunnelId: null,
81
+ reason: "no-tunnel",
82
+ });
83
+ });
84
+ it("handles Neo4j Integer return for the tunnel count", async () => {
85
+ // neo4j-driver returns count() as an Integer object with low/high fields
86
+ // and a toNumber() method. The resolver must unwrap it.
87
+ const integerLike = { low: 3, high: 0, toNumber: () => 3 };
88
+ const session = mockSession([
89
+ { records: [] },
90
+ { records: [record({ n: integerLike })] },
91
+ ]);
92
+ const r = await resolvePublicHostname(session, "acc-1");
93
+ assertMiss(r);
94
+ expect(r.reason).toBe("no-hostname");
95
+ });
96
+ it("scopes the query to accountId — no cross-account leakage", async () => {
97
+ const session = mockSession([
98
+ { records: [] },
99
+ { records: [record({ n: 0 })] },
100
+ ]);
101
+ await resolvePublicHostname(session, "acc-X");
102
+ expect(session.run.mock.calls[0][1]).toEqual({ accountId: "acc-X" });
103
+ expect(session.run.mock.calls[1][1]).toEqual({ accountId: "acc-X" });
104
+ });
105
+ });
106
+ //# sourceMappingURL=public-hostname.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"public-hostname.test.js","sourceRoot":"","sources":["../../src/__tests__/public-hostname.test.ts"],"names":[],"mappings":"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"}
@@ -20,6 +20,7 @@ import QRCode from "qrcode";
20
20
  import { getSession, closeDriver } from "./lib/neo4j.js";
21
21
  import { getOnboardingState, completeOnboardingStep } from "./lib/onboarding.js";
22
22
  import { findSkillOwners, computePluginReadHint } from "./skill-resolution.js";
23
+ import { resolvePublicHostname } from "./lib/public-hostname.js";
23
24
  const server = new McpServer({
24
25
  name: "admin",
25
26
  version: "0.1.0",
@@ -402,6 +403,39 @@ server.tool("system-status", "Check health of all Maxy platform services: Neo4j,
402
403
  .join("\n");
403
404
  return { content: [{ type: "text", text: formatted }] };
404
405
  });
406
+ server.tool("public-hostname", "Resolve this account's canonical public hostname from the CloudflareHostname graph. Returns a single deterministic answer; never guess the property name on hostname nodes (Task 970 — the third recurrence of `RETURN h.hostname` instead of `h.hostnameValue` exhausting the agent's turn budget). Use this immediately after publish-site to construct the full URL.", {}, async () => {
407
+ const TAG = "[admin:public-hostname]";
408
+ const session = getSession();
409
+ try {
410
+ const result = await resolvePublicHostname(session, ACCOUNT_ID);
411
+ if (result.hostname !== null) {
412
+ console.error(`${TAG} resolved accountId=${ACCOUNT_ID} hostname=${result.hostname} isApex=${result.isApex}`);
413
+ const body = `hostname: ${result.hostname}\n` +
414
+ `isApex: ${result.isApex}\n` +
415
+ `tunnelId: ${result.tunnelId}\n` +
416
+ `usage: paste \`https://${result.hostname}<path-slug>\` to the operator — the <path-slug> comes from publish-site.`;
417
+ return { content: [{ type: "text", text: body }] };
418
+ }
419
+ console.error(`${TAG} empty accountId=${ACCOUNT_ID} reason=${result.reason}`);
420
+ const remediation = result.reason === "no-tunnel"
421
+ ? "No Cloudflare tunnel is configured for this account. Run the cloudflare setup-tunnel skill before publishing externally."
422
+ : "A Cloudflare tunnel exists but no public hostname is bound to it. Run the cloudflare setup-hostname skill before publishing externally.";
423
+ return {
424
+ content: [{
425
+ type: "text",
426
+ text: `hostname: (none)\nreason: ${result.reason}\n${remediation}`,
427
+ }],
428
+ };
429
+ }
430
+ catch (err) {
431
+ const errMsg = err instanceof Error ? err.message : String(err);
432
+ console.error(`${TAG} error accountId=${ACCOUNT_ID} message="${errMsg.replace(/"/g, "'")}"`);
433
+ return {
434
+ content: [{ type: "text", text: `error resolving public hostname: ${errMsg}` }],
435
+ isError: true,
436
+ };
437
+ }
438
+ });
405
439
  server.tool("remote-auth-status", "Check whether the remote access password is configured. When not configured, emits a device-bound URL affordance (maxy-device-url fenced block) pointing at the password setup page — this URL opens on the device's own screen when the operator clicks it. The agent never constructs the password file path or runs shell commands — this tool is the single authority.", {}, async () => {
406
440
  const TAG = "[remote-auth-status]";
407
441
  const platformPort = parseInt(PLATFORM_PORT, 10);