@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.
- package/package.json +1 -1
- package/payload/platform/lib/graph-mcp/dist/__tests__/warnings-envelope.test.d.ts +2 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/warnings-envelope.test.d.ts.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/warnings-envelope.test.js +140 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/warnings-envelope.test.js.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-shim-read.d.ts +85 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-shim-read.d.ts.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-shim-read.js +93 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-shim-read.js.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/index.js +82 -11
- package/payload/platform/lib/graph-mcp/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-mcp/src/__tests__/warnings-envelope.test.ts +151 -0
- package/payload/platform/lib/graph-mcp/src/cypher-shim-read.ts +141 -0
- package/payload/platform/lib/graph-mcp/src/index.ts +107 -11
- package/payload/platform/plugins/admin/PLUGIN.md +2 -1
- package/payload/platform/plugins/admin/mcp/dist/__tests__/public-hostname.test.d.ts +2 -0
- package/payload/platform/plugins/admin/mcp/dist/__tests__/public-hostname.test.d.ts.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/__tests__/public-hostname.test.js +106 -0
- package/payload/platform/plugins/admin/mcp/dist/__tests__/public-hostname.test.js.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/index.js +34 -0
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/public-hostname.d.ts +21 -0
- package/payload/platform/plugins/admin/mcp/dist/lib/public-hostname.d.ts.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/lib/public-hostname.js +54 -0
- package/payload/platform/plugins/admin/mcp/dist/lib/public-hostname.js.map +1 -0
- package/payload/platform/plugins/admin/skills/publish-site/SKILL.md +2 -3
- package/payload/platform/plugins/admin/skills/unzip-attachment/SKILL.md +20 -7
- package/payload/platform/plugins/admin/skills/unzip-attachment/__tests__/preflight.sh +148 -0
- package/payload/platform/plugins/admin/skills/unzip-attachment/references/safety.md +53 -18
- package/payload/platform/plugins/docs/references/internals.md +2 -0
- package/payload/platform/templates/specialists/agents/content-producer.md +1 -1
- package/payload/server/chunk-B5VSPQQP.js +11320 -0
- package/payload/server/maxy-edge.js +1 -1
- 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
|
-
|
|
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
|
|
948
|
+
return envelopeStitched;
|
|
865
949
|
}
|
|
866
950
|
}
|
|
867
|
-
return
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
|
|
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 @@
|
|
|
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":"AAAA,qDAAqD;AACrD,EAAE;AACF,4EAA4E;AAC5E,yEAAyE;AACzE,4EAA4E;AAC5E,iEAAiE;AACjE,qEAAqE;AACrE,EAAE;AACF,2EAA2E;AAC3E,yEAAyE;AACzE,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAClD,OAAO,EACL,qBAAqB,GAGtB,MAAM,2BAA2B,CAAC;AAEnC,SAAS,UAAU,CAAC,CAAuB;IACzC,IAAI,CAAC,CAAC,QAAQ,KAAK,IAAI;QAAE,MAAM,IAAI,KAAK,CAAC,wBAAwB,CAAC,CAAC;AACrE,CAAC;AAED,SAAS,MAAM,CAAC,MAA+B;IAC7C,OAAO,EAAE,GAAG,EAAE,CAAC,CAAS,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;AAC3C,CAAC;AAED,SAAS,WAAW,CAAC,OAAmE;IACtF,IAAI,CAAC,GAAG,CAAC,CAAC;IACV,MAAM,GAAG,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,OAAe,EAAE,OAAgC,EAAE,EAAE;QACtE,IAAI,CAAC,IAAI,OAAO,CAAC,MAAM;YAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAClF,OAAO,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IACvC,CAAC,CAAC,CAAC;IACH,MAAM,KAAK,GAAG,EAAE,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IAC7C,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC;AACxB,CAAC;AAED,QAAQ,CAAC,uBAAuB,EAAE,GAAG,EAAE;IACrC,EAAE,CAAC,2FAA2F,EAAE,KAAK,IAAI,EAAE;QACzG,MAAM,OAAO,GAAG,WAAW,CAAC;YAC1B;gBACE,OAAO,EAAE;oBACP,MAAM,CAAC,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC;iBAC1E;aACF;SACF,CAAC,CAAC;QACH,MAAM,CAAC,GAAG,MAAM,qBAAqB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACxD,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;YAChB,QAAQ,EAAE,eAAe;YACzB,MAAM,EAAE,KAAK;YACb,QAAQ,EAAE,SAAS;SACpB,CAAC,CAAC;QACH,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC;QAC7C,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACnD,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,oBAAoB,CAAC,CAAC;QAC/C,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,iBAAiB,CAAC,CAAC;QAC5C,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC;QACrC,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,YAAY,CAAC,CAAC;QACvC,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,wBAAwB,CAAC,CAAC;QACnD,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;QACpC,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,CAAC;IACjD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sEAAsE,EAAE,KAAK,IAAI,EAAE;QACpF,MAAM,OAAO,GAAG,WAAW,CAAC;YAC1B,EAAE,OAAO,EAAE,EAAE,EAAE;YACf,EAAE,OAAO,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE;SAChC,CAAC,CAAC;QACH,MAAM,CAAC,GAAG,MAAM,qBAAqB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACxD,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;YAChB,QAAQ,EAAE,IAAI;YACd,MAAM,EAAE,IAAI;YACZ,QAAQ,EAAE,IAAI;YACd,MAAM,EAAE,aAAa;SACtB,CAAC,CAAC;QACH,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC;QAC7C,MAAM,CAAC,YAAY,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACjD,MAAM,CAAC,YAAY,CAAC,CAAC,SAAS,CAAC,kBAAkB,CAAC,CAAC;IACrD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kEAAkE,EAAE,KAAK,IAAI,EAAE;QAChF,MAAM,OAAO,GAAG,WAAW,CAAC;YAC1B,EAAE,OAAO,EAAE,EAAE,EAAE;YACf,EAAE,OAAO,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE;SAChC,CAAC,CAAC;QACH,MAAM,CAAC,GAAG,MAAM,qBAAqB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACxD,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;YAChB,QAAQ,EAAE,IAAI;YACd,MAAM,EAAE,IAAI;YACZ,QAAQ,EAAE,IAAI;YACd,MAAM,EAAE,WAAW;SACpB,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mDAAmD,EAAE,KAAK,IAAI,EAAE;QACjE,yEAAyE;QACzE,wDAAwD;QACxD,MAAM,WAAW,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC;QAC3D,MAAM,OAAO,GAAG,WAAW,CAAC;YAC1B,EAAE,OAAO,EAAE,EAAE,EAAE;YACf,EAAE,OAAO,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC,EAAE;SAC1C,CAAC,CAAC;QACH,MAAM,CAAC,GAAG,MAAM,qBAAqB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACxD,UAAU,CAAC,CAAC,CAAC,CAAC;QACd,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;IACvC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0DAA0D,EAAE,KAAK,IAAI,EAAE;QACxE,MAAM,OAAO,GAAG,WAAW,CAAC;YAC1B,EAAE,OAAO,EAAE,EAAE,EAAE;YACf,EAAE,OAAO,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE;SAChC,CAAC,CAAC;QACH,MAAM,qBAAqB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAC9C,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,CAAC;QACrE,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,CAAC;IACvE,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
|
@@ -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);
|