jui-tools-mcp-server 2.11.1 → 2.12.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/data/screen_identity.json +69 -9
- package/dist/auto_reload.d.ts +16 -0
- package/dist/auto_reload.js +33 -0
- package/dist/index.js +4 -0
- package/dist/spec_loader.d.ts +30 -0
- package/dist/spec_loader.js +45 -0
- package/dist/tools/spec/get_data_source.js +4 -2
- package/package.json +1 -1
- package/scripts/fetch-definitions.js +1 -1
- package/src/auto_reload.ts +38 -0
- package/src/index.ts +4 -0
- package/src/spec_loader.ts +54 -0
- package/src/tools/spec/get_data_source.ts +4 -2
- package/tests/a_tool_call_reloads_the_canon_so_no_session_restarts.test.ts +176 -0
|
@@ -89,12 +89,16 @@
|
|
|
89
89
|
"declaration": {
|
|
90
90
|
"location": "jui.config.json",
|
|
91
91
|
"key": "test.appOwnedScreens",
|
|
92
|
-
"shape": "Array of entries. An entry is a screen id, or an object { id, group } when the screen also needs diagram metadata.
|
|
92
|
+
"shape": "Array of entries. An entry is a screen id, or an object { id, group, transitions } when the screen also needs diagram metadata or declares outgoing transitions. All forms are equivalent for classification.",
|
|
93
93
|
"example": [
|
|
94
94
|
"tokushoho",
|
|
95
95
|
{
|
|
96
96
|
"id": "company",
|
|
97
|
-
"group": "static"
|
|
97
|
+
"group": "static",
|
|
98
|
+
"transitions": [
|
|
99
|
+
"Licenses",
|
|
100
|
+
"Tokushoho"
|
|
101
|
+
]
|
|
98
102
|
},
|
|
99
103
|
{
|
|
100
104
|
"id": "licenses",
|
|
@@ -104,6 +108,11 @@
|
|
|
104
108
|
]
|
|
105
109
|
}
|
|
106
110
|
],
|
|
111
|
+
"transitions": {
|
|
112
|
+
"value": "An array of raw destinations, in the SAME vocabulary a spec's transitions[].destination uses (classified by classify_destination with the face's aliases).",
|
|
113
|
+
"readBy": "The flow-diagram generator (document_tools spec_graph) — it draws them and flowTestCheck accepts them. Classification and code generation ignore them.",
|
|
114
|
+
"rationale": "The diagram is drawn from specs only (2026-09-10), and an app-owned screen has no spec — a spec for a screen with nothing to generate describes nothing. Without this key every flow test that leaves an app-owned screen (a footer link from the company page to the licenses page, measured: 2 on one face) would be an error with no way to comply."
|
|
115
|
+
},
|
|
107
116
|
"group": {
|
|
108
117
|
"value": "A group name, or an array of them — the same shape a screen test's metadata.group takes.",
|
|
109
118
|
"readBy": "The flow-diagram generator only. Classification and code generation read the id and ignore the rest of the object.",
|
|
@@ -296,8 +305,17 @@
|
|
|
296
305
|
]
|
|
297
306
|
},
|
|
298
307
|
"diagram": {
|
|
299
|
-
"description": "The screen-transition diagram is
|
|
300
|
-
"nodeSource": "
|
|
308
|
+
"description": "The screen-transition diagram is drawn from the screen SPECS — <screen spec>.transitions[].destination, plus test.appOwnedScreens[].transitions for a screen that has no spec — and the flow tests are CHECKED against it. Ruled 2026-09-10.",
|
|
309
|
+
"nodeSource": "ONE source: spec transitions, normalized through specTransitions below, plus the app-owned declarations (appOwnedScreens.declaration.transitions). Flow tests are NOT a source since 2026-09-10; they are the check (flowTestCheck). History: from 2026-09-09 this key declared TWO sources while document_tools read only the first (flow tests) and had ZERO readers of specTransitions — the diagram answered 'No flow tests found' on a face holding 48 specs. A declaration with no reader is a sentence, not a rule. The reader is now document_tools/.../mermaid/spec_graph.py, and its arms fail when the generator reads a flow test as a source.",
|
|
310
|
+
"drawingPipeline": [
|
|
311
|
+
"readSpecs (spec stems are the screen ids)",
|
|
312
|
+
"classifyDestinations (specTransitions.kinds)",
|
|
313
|
+
"buildForwardEdges (kind screen)",
|
|
314
|
+
"deriveReturnEdges (kind back → a dotted edge to every screen with a forward edge into the declarer)",
|
|
315
|
+
"terminalNodes (kind external)",
|
|
316
|
+
"groupByScreenTestMetadata"
|
|
317
|
+
],
|
|
318
|
+
"pipelineNote": "`pipeline` is the FLOW-TEST walk (resolve → canonicalize → collapse → build edges). It no longer draws anything; flowTestCheck runs it to obtain what the tests DO and compares that with what the specs declare.",
|
|
301
319
|
"pipeline": [
|
|
302
320
|
"resolve",
|
|
303
321
|
"canonicalize",
|
|
@@ -307,8 +325,25 @@
|
|
|
307
325
|
"rules": [
|
|
308
326
|
"Self-loops are discarded (they are the collapse of a screen onto itself).",
|
|
309
327
|
"An edge caused by a 'back' action is a return edge and is rendered distinctly from a forward transition.",
|
|
310
|
-
"Node ids are sanitized for the diagram language; a screen id is never emitted raw into a diagram identifier position."
|
|
328
|
+
"Node ids are sanitized for the diagram language; a screen id is never emitted raw into a diagram identifier position.",
|
|
329
|
+
"A `back` declaration carries no target: it is drawn as a return edge (dotted) to each screen that has a forward edge into the declaring screen. Derived from the specs, never from a test.",
|
|
330
|
+
"An `external` destination is drawn as a terminal node; `none` draws nothing (a positive declaration); `unknown` and an unmapped `route` draw nothing and are treated as ABSENT — counted and listed, not errors by themselves (ruled 2026-09-10: 解決できない宣言は「無い」扱い)."
|
|
311
331
|
],
|
|
332
|
+
"flowTestCheck": {
|
|
333
|
+
"rule": "Every FORWARD transition a flow test performs (consecutive distinct screens after resolve/canonicalize/collapse) must be an edge the specs declare: a forward edge, or the return edge derived from a `back` declaration (X returns to each screen that pushes it — a flow that leaves a sheet by tapping save and lands on its opener performs that return as a forward step). One that is neither is an ERROR.",
|
|
334
|
+
"ruledOn": "2026-09-10 — 'flowdiagram は spec だけ見て書く。flow test で遷移が見つかって、それが spec にない時はエラーを吐く'",
|
|
335
|
+
"exempt": "Back steps (action `back`). A return through the stack is the inverse of a transition the check already saw — and a spec `back` declaration has no target to compare with.",
|
|
336
|
+
"unresolvedIsAbsent": "A destination the classifier cannot resolve (kind unknown, or route with no mapping) is NOT in the spec for the purpose of this check. So a flow test that navigates to the screen such a destination was meant to name is an error, and the error names the unresolved destination(s) on that source: that is usually the fix.",
|
|
337
|
+
"reasons": [
|
|
338
|
+
"<from> has no spec",
|
|
339
|
+
"<from>'s spec declares no transition to <to>",
|
|
340
|
+
"the app-owned screen <from> declares no transition to <to> (jui.config.json test.appOwnedScreens[].transitions)"
|
|
341
|
+
],
|
|
342
|
+
"severity": "error. `jsonui-doc generate html` writes every page (the diagram page lists the errors), prints one `ERROR [doc-diagram]:` line per transition, and exits 1. Not covered by --allow-partial (that accepts missing pages; this is a wrong spec). `generate mermaid` exits 1 likewise.",
|
|
343
|
+
"reported": "per owner (app), with the flow test's name and file, from, to, and the reason. A count without the list is not a report.",
|
|
344
|
+
"noSpecDirectory": "A flow group with no spec directory anywhere gets ONE `WARNING [doc-diagram]` naming how many flow test files went unchecked, and no diagram. Absence of the directory is a configuration state, not a spec omission.",
|
|
345
|
+
"readBy": "document_tools/jsonui_doc_cli/test_doc/mermaid/generator.py build_diagram; the exit code in jsonui_doc_cli/cli.py cmd_generate_html"
|
|
346
|
+
},
|
|
312
347
|
"specTransitions": {
|
|
313
348
|
"source": "<screen spec>.transitions[].destination",
|
|
314
349
|
"description": "A spec transition's destination is FREE PROSE, not an identifier. The validator requires only that the key exists (condition + destination), so nothing has ever constrained its shape, and the corpus shows at least six different kinds of value in that one column.",
|
|
@@ -324,11 +359,11 @@
|
|
|
324
359
|
"description": "CLOSED vocabulary. It exists so that a destination which is not a screen id stops being reported as a defect. Declaring only 'screen' would make 119 of 271 measured destinations spec errors, when they are other kinds of transition — that turns a reporting gap into a rejection of correct input, which is the worse direction.",
|
|
325
360
|
"values": [
|
|
326
361
|
{"kind": "screen", "meaning": "resolves to a screen id in this face's id space", "drawn": "a node and an edge"},
|
|
327
|
-
{"kind": "route", "meaning": "a URL path owned by a web router, e.g. '/admin/login'", "drawn": "
|
|
362
|
+
{"kind": "route", "meaning": "a URL path owned by a web router, e.g. '/admin/login'", "drawn": "nothing until the route is mapped to a screen; until then it is reported with the unknowns and treated as absent"},
|
|
328
363
|
{"kind": "external", "meaning": "leaves the app entirely (browser, tel:, map app, mailer)", "drawn": "a terminal node, never a screen"},
|
|
329
364
|
{"kind": "none", "meaning": "declares that no screen transition happens (in-page tab switch, SPA state change)", "drawn": "nothing. This is a POSITIVE declaration, not a missing value"},
|
|
330
|
-
{"kind": "back", "meaning": "returns through the navigation stack (pop, dismiss, previous screen)", "drawn": "a return edge
|
|
331
|
-
{"kind": "unknown", "meaning": "none of the above matched", "drawn": "nothing, and it is COUNTED AND LISTED — see unresolvedReporting"}
|
|
365
|
+
{"kind": "back", "meaning": "returns through the navigation stack (pop, dismiss, previous screen)", "drawn": "a dotted return edge to each screen that has a forward edge into the declaring screen (derived from the specs)"},
|
|
366
|
+
{"kind": "unknown", "meaning": "none of the above matched", "drawn": "nothing, and it is COUNTED AND LISTED — see unresolvedReporting. Treated as ABSENT by the diagram and by flowTestCheck (ruled 2026-09-10)"}
|
|
332
367
|
]
|
|
333
368
|
},
|
|
334
369
|
"normalization": {
|
|
@@ -339,9 +374,33 @@
|
|
|
339
374
|
"strip a trailing parenthetical, full-width or half-width, then split on ' or ', '/', and '、' and try each part",
|
|
340
375
|
"apply the face's declared aliases (below)"
|
|
341
376
|
],
|
|
377
|
+
"multipleScreens": "A destination whose split parts (pipeline step 2) resolve to MORE THAN ONE screen — `Chat or Mypage(source依存…)` — declares a transition to each. classify_destination returns the first match; the diagram asks jui_cli.core.screen_identity.destination_parts for the parts and classifies each. One resolving part is the classifier's single answer, unchanged.",
|
|
378
|
+
"parentSpecs": "A `screen_parent_spec` carries no transitions; its `subSpecs[].file` entries (type `screen_sub_spec`) do. The diagram merges them under the parent's screen id (the parent's stem). Measured 2026-09-10: one face's main screen had 0 transitions in the parent and 15 in the sub spec, and reading the parent alone produced false errors for 42 flow tests.",
|
|
379
|
+
"flowTestConditions": "For the CHECK: a flow test runs once per context (platform, responsive class), and a step gated by `when.platform` / `when.responsive` — any `when` key with a string or list-of-strings value — runs only in its own. Two sibling steps gated ios / android, or regular / compact, are never consecutive; the sequence is built once per combination of the `when` values the flow uses and the edges are the union (measured 2026-09-10: 3 invented transitions on one face).",
|
|
342
380
|
"aliases": {
|
|
343
381
|
"shape": "each declared alias is (position, affix); position is drawn from the closed set below",
|
|
344
382
|
"positions": ["prefix", "suffix"],
|
|
383
|
+
"declaration": {
|
|
384
|
+
"location": "jui.config.json",
|
|
385
|
+
"key": "spec.transitionAliases",
|
|
386
|
+
"shape": "Array of { position: 'prefix' | 'suffix', affix: '<text>' }. A wrong position is REFUSED (the run fails naming the entry), never skipped — an alias that silently does not fire looks exactly like a face that declared nothing.",
|
|
387
|
+
"example": [
|
|
388
|
+
{
|
|
389
|
+
"position": "prefix",
|
|
390
|
+
"affix": "Web"
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
"position": "suffix",
|
|
394
|
+
"affix": "画面"
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
"position": "suffix",
|
|
398
|
+
"affix": "View"
|
|
399
|
+
}
|
|
400
|
+
],
|
|
401
|
+
"readBy": "jui_cli.core.project_config.declared_transition_aliases → jui_cli.core.screen_identity.parse_transition_aliases; consumed by document_tools spec_graph.build_spec_graph through classify_destination(aliases=…)",
|
|
402
|
+
"history": "Declared as per-face from 2026-09-09 with the SHAPE only — no key, no reader. Measured 2026-09-10: 0 faces could declare one. Added with the reader."
|
|
403
|
+
},
|
|
345
404
|
"positionsNote": "THE TWO POSITIONS OBSERVED SO FAR, not all of them. Infix and partial matches are NOT covered, and saying which is which matters: a generalization that names its coverage makes the third example a counterexample that asks for a redesign, while one that says 'all' makes the same example an exception to be pushed in sideways, leaving the mechanism bent.",
|
|
346
405
|
"measured": "prefix 'Web' resolves 50 of one face's 93 destinations; suffix '画面' resolves 10 (9 distinct spellings) of another face's 69. The two sit at different ends, which is why the mechanism carries a position rather than a bare string. A THIRD affix then turned up that does not behave like those two: suffix 'View' resolves 8 on one face and 2 on ANOTHER, so an affix is not necessarily one face's convention. Per-face declaration still expresses it — each face declares it separately — but the earlier reading that 'an affix helps exactly one face' was drawn from a sample of two. Whether to declare it is the FACE's call, not the library's — the mechanism already accepts it — so the numbers a face needs are recorded here rather than argued: declaring suffix 'View' resolves 10 destinations (8 on one face, 2 on another) and leaves 5 occurrences / 4 spellings that contain 'View' still unresolved, because stripping it yields no id — FullscreenImageViewer, LicensesView, and the Privacy/Terms WebView pair, whose kind is a separate question (embedded page or external browser) that no measurement here answers.",
|
|
347
406
|
"scopingIsARiskChoiceNotAMeasuredOne": {
|
|
@@ -352,7 +411,8 @@
|
|
|
352
411
|
}
|
|
353
412
|
},
|
|
354
413
|
"unresolvedReporting": {
|
|
355
|
-
"rule": "The count of 'unknown' destinations is reported PER FACE, never only as a total, together with the scanned total it was drawn from.",
|
|
414
|
+
"rule": "The count of 'unknown' destinations is reported PER FACE, never only as a total, together with the scanned total it was drawn from.", "treatment": "ABSENT (ruled 2026-09-10). Not drawn, not an error by itself, listed per face with the raw value and the reason the classifier gave. It becomes an error only when a flow test performs the transition the unresolved destination was meant to declare.",
|
|
415
|
+
|
|
356
416
|
"rationale": "Measured unresolved rates were 28% / 61% / 50% / 100%. Summed, the face at 100% disappears into the average — and a face that resolves nothing looks exactly like a face with nothing to resolve. A bare zero, or a bare average, cannot tell an instrument that found nothing from one that never reached the corpus."
|
|
357
417
|
}
|
|
358
418
|
},
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { SpecLoader } from "./spec_loader.js";
|
|
3
|
+
/**
|
|
4
|
+
* Make every tool registered on `server` refresh the canon before it runs.
|
|
5
|
+
*
|
|
6
|
+
* Installed ONCE, before any `register(...)` call, by wrapping `server.tool`:
|
|
7
|
+
* the last function argument of each registration is the handler, and it is
|
|
8
|
+
* replaced with one that calls `loader.refreshIfChanged()` first. One wrap,
|
|
9
|
+
* 41 tools, no per-tool code — which is the point: a per-tool call is the
|
|
10
|
+
* kind of thing the 42nd tool forgets.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ Groups B–F read `ServerConfig`, not the loader, and are wrapped anyway.
|
|
13
|
+
* The cost is ~0.2 ms per call; the alternative is a list of "tools that need
|
|
14
|
+
* it" that drifts from the list of tools that read the canon.
|
|
15
|
+
*/
|
|
16
|
+
export declare function installAutoReload(server: McpServer, loader: SpecLoader): void;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make every tool registered on `server` refresh the canon before it runs.
|
|
3
|
+
*
|
|
4
|
+
* Installed ONCE, before any `register(...)` call, by wrapping `server.tool`:
|
|
5
|
+
* the last function argument of each registration is the handler, and it is
|
|
6
|
+
* replaced with one that calls `loader.refreshIfChanged()` first. One wrap,
|
|
7
|
+
* 41 tools, no per-tool code — which is the point: a per-tool call is the
|
|
8
|
+
* kind of thing the 42nd tool forgets.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ Groups B–F read `ServerConfig`, not the loader, and are wrapped anyway.
|
|
11
|
+
* The cost is ~0.2 ms per call; the alternative is a list of "tools that need
|
|
12
|
+
* it" that drifts from the list of tools that read the canon.
|
|
13
|
+
*/
|
|
14
|
+
export function installAutoReload(server, loader) {
|
|
15
|
+
const original = server.tool.bind(server);
|
|
16
|
+
server.tool = (...args) => {
|
|
17
|
+
let index = -1;
|
|
18
|
+
for (let i = args.length - 1; i >= 0; i--) {
|
|
19
|
+
if (typeof args[i] === "function") {
|
|
20
|
+
index = i;
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (index >= 0) {
|
|
25
|
+
const handler = args[index];
|
|
26
|
+
args[index] = async (...handlerArgs) => {
|
|
27
|
+
loader.refreshIfChanged();
|
|
28
|
+
return handler(...handlerArgs);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return original(...args);
|
|
32
|
+
};
|
|
33
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { SpecLoader, mcpRootFromImportMetaUrl } from "./spec_loader.js";
|
|
5
|
+
import { installAutoReload } from "./auto_reload.js";
|
|
5
6
|
import { ServerConfig } from "./config.js";
|
|
6
7
|
// --- Group A: Component Spec Lookup ---
|
|
7
8
|
import { register as registerLookupComponent } from "./tools/spec/lookup_component.js";
|
|
@@ -67,6 +68,9 @@ const server = new McpServer({
|
|
|
67
68
|
// JSONUI_CLI_PATH env > ./.jsonui-cli/ > ~/.jsonui-cli/ > bundled snapshot in data/.
|
|
68
69
|
const mcpRoot = mcpRootFromImportMetaUrl(import.meta.url);
|
|
69
70
|
const loader = new SpecLoader(mcpRoot);
|
|
71
|
+
// Every tool re-reads the canon when its content changed (2.12.0). This
|
|
72
|
+
// replaces "restart the MCP server after each jsonui-cli distribution".
|
|
73
|
+
installAutoReload(server, loader);
|
|
70
74
|
const source = loader.getDataSource();
|
|
71
75
|
log(`attribute_definitions: [${source.attributeDefinitions.layer}] ${source.attributeDefinitions.path} (${source.attributeDefinitions.freshness})`);
|
|
72
76
|
log(`component_metadata: [${source.componentMetadata.layer}] ${source.componentMetadata.path} (${source.componentMetadata.freshness})`);
|
package/dist/spec_loader.d.ts
CHANGED
|
@@ -50,6 +50,14 @@ export interface FileInfo {
|
|
|
50
50
|
*/
|
|
51
51
|
contentHash: string;
|
|
52
52
|
}
|
|
53
|
+
/** One automatic reload attempt. `ok: false` means the previous content is
|
|
54
|
+
* still being served — a file did not parse — and `error` says which. */
|
|
55
|
+
export interface ReloadRecord {
|
|
56
|
+
at: string;
|
|
57
|
+
changed: string[];
|
|
58
|
+
ok: boolean;
|
|
59
|
+
error?: string;
|
|
60
|
+
}
|
|
53
61
|
export interface DataSourceInfo {
|
|
54
62
|
attributeDefinitions: FileInfo;
|
|
55
63
|
componentMetadata: FileInfo;
|
|
@@ -89,7 +97,29 @@ export declare class SpecLoader {
|
|
|
89
97
|
/** When this process read the files. Data is cached in memory from then
|
|
90
98
|
* on, so a file edited later is not what the server is serving. */
|
|
91
99
|
private loadedAt;
|
|
100
|
+
/** What the last automatic reload did — see `refreshIfChanged`. */
|
|
101
|
+
private lastReload;
|
|
92
102
|
constructor(mcpRootDir: string);
|
|
103
|
+
/**
|
|
104
|
+
* Re-read the canon when its CONTENT changed since this process loaded it.
|
|
105
|
+
*
|
|
106
|
+
* 🚨 WHY THIS EXISTS. Every jsonui-cli distribution replaces `~/.jsonui-cli`
|
|
107
|
+
* under a running server, and until 2.12.0 the only remedy was "restart the
|
|
108
|
+
* MCP server" — in every session, on every release. Measured 2026-09-10:
|
|
109
|
+
* the check costs ~0.2 ms per call (seven sha256s of small files), which is
|
|
110
|
+
* nothing against a tool call, so every tool handler runs it first
|
|
111
|
+
* (`installAutoReload`) and a restart is no longer part of a distribution.
|
|
112
|
+
*
|
|
113
|
+
* ⚠️ A reload is ALL OR NOTHING. A distribution writes files one by one, so
|
|
114
|
+
* a call can land on a half-written JSON. The fresh state is built in a
|
|
115
|
+
* separate instance and swapped in only when every file parsed; on failure
|
|
116
|
+
* the server keeps serving the last good content and records the error
|
|
117
|
+
* (`getLastReload`), and the next call tries again.
|
|
118
|
+
*
|
|
119
|
+
* Returns the paths whose content differed (empty = nothing to do).
|
|
120
|
+
*/
|
|
121
|
+
refreshIfChanged(): string[];
|
|
122
|
+
getLastReload(): ReloadRecord | null;
|
|
93
123
|
getComponent(name: string): ComponentSpec | null;
|
|
94
124
|
getComponentWithCommon(name: string): any;
|
|
95
125
|
getAttribute(name: string): any;
|
package/dist/spec_loader.js
CHANGED
|
@@ -64,10 +64,55 @@ export class SpecLoader {
|
|
|
64
64
|
/** When this process read the files. Data is cached in memory from then
|
|
65
65
|
* on, so a file edited later is not what the server is serving. */
|
|
66
66
|
loadedAt = new Date().toISOString();
|
|
67
|
+
/** What the last automatic reload did — see `refreshIfChanged`. */
|
|
68
|
+
lastReload = null;
|
|
67
69
|
constructor(mcpRootDir) {
|
|
68
70
|
this.mcpRootDir = mcpRootDir;
|
|
69
71
|
this.load();
|
|
70
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Re-read the canon when its CONTENT changed since this process loaded it.
|
|
75
|
+
*
|
|
76
|
+
* 🚨 WHY THIS EXISTS. Every jsonui-cli distribution replaces `~/.jsonui-cli`
|
|
77
|
+
* under a running server, and until 2.12.0 the only remedy was "restart the
|
|
78
|
+
* MCP server" — in every session, on every release. Measured 2026-09-10:
|
|
79
|
+
* the check costs ~0.2 ms per call (seven sha256s of small files), which is
|
|
80
|
+
* nothing against a tool call, so every tool handler runs it first
|
|
81
|
+
* (`installAutoReload`) and a restart is no longer part of a distribution.
|
|
82
|
+
*
|
|
83
|
+
* ⚠️ A reload is ALL OR NOTHING. A distribution writes files one by one, so
|
|
84
|
+
* a call can land on a half-written JSON. The fresh state is built in a
|
|
85
|
+
* separate instance and swapped in only when every file parsed; on failure
|
|
86
|
+
* the server keeps serving the last good content and records the error
|
|
87
|
+
* (`getLastReload`), and the next call tries again.
|
|
88
|
+
*
|
|
89
|
+
* Returns the paths whose content differed (empty = nothing to do).
|
|
90
|
+
*/
|
|
91
|
+
refreshIfChanged() {
|
|
92
|
+
const changed = this.getChangedSinceLoad();
|
|
93
|
+
if (changed.length === 0)
|
|
94
|
+
return [];
|
|
95
|
+
const at = new Date().toISOString();
|
|
96
|
+
try {
|
|
97
|
+
const fresh = new SpecLoader(this.mcpRootDir);
|
|
98
|
+
// Own enumerable fields only — the private state, all of it, in one
|
|
99
|
+
// step. Nothing observes a half-swapped loader.
|
|
100
|
+
Object.assign(this, fresh);
|
|
101
|
+
this.lastReload = { at, changed, ok: true };
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
this.lastReload = {
|
|
105
|
+
at,
|
|
106
|
+
changed,
|
|
107
|
+
ok: false,
|
|
108
|
+
error: error instanceof Error ? error.message : String(error),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return changed;
|
|
112
|
+
}
|
|
113
|
+
getLastReload() {
|
|
114
|
+
return this.lastReload;
|
|
115
|
+
}
|
|
71
116
|
// ----- public API ------------------------------------------------------
|
|
72
117
|
getComponent(name) {
|
|
73
118
|
const key = name.toLowerCase();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export function register(server, loader) {
|
|
2
|
-
server.tool("get_data_source", "Report where the MCP server loaded EVERY canonical data file from — attribute_definitions.json (raw schema), component_metadata.json (presentation metadata), screen_identity.json and binding_semantics.json. Shows layer (env / cwd / home / bundled), the absolute path actually read, file mtime and freshness per file, plus when this server process loaded them. Use it when the CLI and the MCP seem to disagree about the canon:
|
|
2
|
+
server.tool("get_data_source", "Report where the MCP server loaded EVERY canonical data file from — attribute_definitions.json (raw schema), component_metadata.json (presentation metadata), screen_identity.json and binding_semantics.json. Shows layer (env / cwd / home / bundled), the absolute path actually read, file mtime and freshness per file, plus when this server process loaded them. Use it when the CLI and the MCP seem to disagree about the canon: since 2.12.0 every tool call re-reads a file whose CONTENT changed (sha256, not mtime), so a restart is not needed after a jsonui-cli distribution; `lastReload` says when that last happened and whether it succeeded, and `staleInMemory` is non-empty only when a reload FAILED (a file did not parse) and the server is still serving the previous content.", {}, async () => {
|
|
3
3
|
const info = loader.getDataSource();
|
|
4
4
|
// 🚨 THE SAME SEVEN THE STALENESS CHECK WATCHES. This list held five
|
|
5
5
|
// while `getChangedSinceLoad` tracked seven, so `staleInMemory` could
|
|
@@ -22,6 +22,7 @@ export function register(server, loader) {
|
|
|
22
22
|
// however current the file on disk looks.
|
|
23
23
|
const loadedAt = loader.getLoadedAt();
|
|
24
24
|
const changedSinceLoad = loader.getChangedSinceLoad();
|
|
25
|
+
const lastReload = loader.getLastReload() ?? undefined;
|
|
25
26
|
return {
|
|
26
27
|
content: [
|
|
27
28
|
{
|
|
@@ -37,6 +38,7 @@ export function register(server, loader) {
|
|
|
37
38
|
componentCount: info.componentCount,
|
|
38
39
|
commonAttributeCount: info.commonAttributeCount,
|
|
39
40
|
loadedAt,
|
|
41
|
+
lastReload,
|
|
40
42
|
staleInMemory: changedSinceLoad.length > 0 ? changedSinceLoad : undefined,
|
|
41
43
|
// `freshness` is a property of the FILE (how old it is);
|
|
42
44
|
// `staleInMemory` is a property of THIS SERVER (whether what
|
|
@@ -45,7 +47,7 @@ export function register(server, loader) {
|
|
|
45
47
|
// "fresh" on every file while the server serves something
|
|
46
48
|
// else. The hint names which one fired.
|
|
47
49
|
hint: changedSinceLoad.length
|
|
48
|
-
? "The CONTENT of these files differs from what this server loaded (compared by sha256, not by timestamp — a distribution moves every mtime without changing bytes). The server
|
|
50
|
+
? "The CONTENT of these files differs from what this server loaded (compared by sha256, not by timestamp — a distribution moves every mtime without changing bytes) AND the automatic reload did not replace it — see lastReload.error. The server keeps serving the last content that parsed; fix the file (or wait for the distribution to finish writing it) and call again."
|
|
49
51
|
: stale
|
|
50
52
|
? "At least one data file is > 90 days old. Re-fetch via `npm rebuild jui-tools-mcp-server` or point JSONUI_CLI_PATH at a fresher checkout."
|
|
51
53
|
: undefined,
|
package/package.json
CHANGED
|
@@ -44,7 +44,7 @@ const DATA_DIR = join(PROJECT_ROOT, "data");
|
|
|
44
44
|
// 3. `npm run fetch-definitions` and commit the data/ diff together with
|
|
45
45
|
// this pin in one commit
|
|
46
46
|
// JSONUI_CLI_RAW_BASE still overrides the whole base URL for manual testing.
|
|
47
|
-
const JSONUI_CLI_TAG = "v1.8.
|
|
47
|
+
const JSONUI_CLI_TAG = "v1.8.66";
|
|
48
48
|
const BASE_URL =
|
|
49
49
|
process.env.JSONUI_CLI_RAW_BASE ||
|
|
50
50
|
`https://raw.githubusercontent.com/Tai-Kimura/jsonui-cli/${JSONUI_CLI_TAG}`;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { SpecLoader } from "./spec_loader.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Make every tool registered on `server` refresh the canon before it runs.
|
|
6
|
+
*
|
|
7
|
+
* Installed ONCE, before any `register(...)` call, by wrapping `server.tool`:
|
|
8
|
+
* the last function argument of each registration is the handler, and it is
|
|
9
|
+
* replaced with one that calls `loader.refreshIfChanged()` first. One wrap,
|
|
10
|
+
* 41 tools, no per-tool code — which is the point: a per-tool call is the
|
|
11
|
+
* kind of thing the 42nd tool forgets.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ Groups B–F read `ServerConfig`, not the loader, and are wrapped anyway.
|
|
14
|
+
* The cost is ~0.2 ms per call; the alternative is a list of "tools that need
|
|
15
|
+
* it" that drifts from the list of tools that read the canon.
|
|
16
|
+
*/
|
|
17
|
+
export function installAutoReload(server: McpServer, loader: SpecLoader): void {
|
|
18
|
+
const original = (server.tool as (...args: unknown[]) => unknown).bind(server);
|
|
19
|
+
(server as unknown as { tool: (...args: unknown[]) => unknown }).tool = (
|
|
20
|
+
...args: unknown[]
|
|
21
|
+
) => {
|
|
22
|
+
let index = -1;
|
|
23
|
+
for (let i = args.length - 1; i >= 0; i--) {
|
|
24
|
+
if (typeof args[i] === "function") {
|
|
25
|
+
index = i;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (index >= 0) {
|
|
30
|
+
const handler = args[index] as (...handlerArgs: unknown[]) => unknown;
|
|
31
|
+
args[index] = async (...handlerArgs: unknown[]) => {
|
|
32
|
+
loader.refreshIfChanged();
|
|
33
|
+
return handler(...handlerArgs);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return original(...args);
|
|
37
|
+
};
|
|
38
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { SpecLoader, mcpRootFromImportMetaUrl } from "./spec_loader.js";
|
|
6
|
+
import { installAutoReload } from "./auto_reload.js";
|
|
6
7
|
import { ServerConfig } from "./config.js";
|
|
7
8
|
|
|
8
9
|
// --- Group A: Component Spec Lookup ---
|
|
@@ -78,6 +79,9 @@ const server = new McpServer({
|
|
|
78
79
|
// JSONUI_CLI_PATH env > ./.jsonui-cli/ > ~/.jsonui-cli/ > bundled snapshot in data/.
|
|
79
80
|
const mcpRoot = mcpRootFromImportMetaUrl(import.meta.url);
|
|
80
81
|
const loader = new SpecLoader(mcpRoot);
|
|
82
|
+
// Every tool re-reads the canon when its content changed (2.12.0). This
|
|
83
|
+
// replaces "restart the MCP server after each jsonui-cli distribution".
|
|
84
|
+
installAutoReload(server, loader);
|
|
81
85
|
const source = loader.getDataSource();
|
|
82
86
|
log(
|
|
83
87
|
`attribute_definitions: [${source.attributeDefinitions.layer}] ${source.attributeDefinitions.path} (${source.attributeDefinitions.freshness})`
|
package/src/spec_loader.ts
CHANGED
|
@@ -81,6 +81,15 @@ export interface FileInfo {
|
|
|
81
81
|
contentHash: string;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** One automatic reload attempt. `ok: false` means the previous content is
|
|
85
|
+
* still being served — a file did not parse — and `error` says which. */
|
|
86
|
+
export interface ReloadRecord {
|
|
87
|
+
at: string;
|
|
88
|
+
changed: string[];
|
|
89
|
+
ok: boolean;
|
|
90
|
+
error?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
84
93
|
export interface DataSourceInfo {
|
|
85
94
|
attributeDefinitions: FileInfo;
|
|
86
95
|
componentMetadata: FileInfo;
|
|
@@ -153,11 +162,56 @@ export class SpecLoader {
|
|
|
153
162
|
/** When this process read the files. Data is cached in memory from then
|
|
154
163
|
* on, so a file edited later is not what the server is serving. */
|
|
155
164
|
private loadedAt: string = new Date().toISOString();
|
|
165
|
+
/** What the last automatic reload did — see `refreshIfChanged`. */
|
|
166
|
+
private lastReload: ReloadRecord | null = null;
|
|
156
167
|
|
|
157
168
|
constructor(private mcpRootDir: string) {
|
|
158
169
|
this.load();
|
|
159
170
|
}
|
|
160
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Re-read the canon when its CONTENT changed since this process loaded it.
|
|
174
|
+
*
|
|
175
|
+
* 🚨 WHY THIS EXISTS. Every jsonui-cli distribution replaces `~/.jsonui-cli`
|
|
176
|
+
* under a running server, and until 2.12.0 the only remedy was "restart the
|
|
177
|
+
* MCP server" — in every session, on every release. Measured 2026-09-10:
|
|
178
|
+
* the check costs ~0.2 ms per call (seven sha256s of small files), which is
|
|
179
|
+
* nothing against a tool call, so every tool handler runs it first
|
|
180
|
+
* (`installAutoReload`) and a restart is no longer part of a distribution.
|
|
181
|
+
*
|
|
182
|
+
* ⚠️ A reload is ALL OR NOTHING. A distribution writes files one by one, so
|
|
183
|
+
* a call can land on a half-written JSON. The fresh state is built in a
|
|
184
|
+
* separate instance and swapped in only when every file parsed; on failure
|
|
185
|
+
* the server keeps serving the last good content and records the error
|
|
186
|
+
* (`getLastReload`), and the next call tries again.
|
|
187
|
+
*
|
|
188
|
+
* Returns the paths whose content differed (empty = nothing to do).
|
|
189
|
+
*/
|
|
190
|
+
refreshIfChanged(): string[] {
|
|
191
|
+
const changed = this.getChangedSinceLoad();
|
|
192
|
+
if (changed.length === 0) return [];
|
|
193
|
+
const at = new Date().toISOString();
|
|
194
|
+
try {
|
|
195
|
+
const fresh = new SpecLoader(this.mcpRootDir);
|
|
196
|
+
// Own enumerable fields only — the private state, all of it, in one
|
|
197
|
+
// step. Nothing observes a half-swapped loader.
|
|
198
|
+
Object.assign(this, fresh);
|
|
199
|
+
this.lastReload = { at, changed, ok: true };
|
|
200
|
+
} catch (error) {
|
|
201
|
+
this.lastReload = {
|
|
202
|
+
at,
|
|
203
|
+
changed,
|
|
204
|
+
ok: false,
|
|
205
|
+
error: error instanceof Error ? error.message : String(error),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return changed;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
getLastReload(): ReloadRecord | null {
|
|
212
|
+
return this.lastReload;
|
|
213
|
+
}
|
|
214
|
+
|
|
161
215
|
// ----- public API ------------------------------------------------------
|
|
162
216
|
|
|
163
217
|
getComponent(name: string): ComponentSpec | null {
|
|
@@ -4,7 +4,7 @@ import { SpecLoader } from "../../spec_loader.js";
|
|
|
4
4
|
export function register(server: McpServer, loader: SpecLoader) {
|
|
5
5
|
server.tool(
|
|
6
6
|
"get_data_source",
|
|
7
|
-
"Report where the MCP server loaded EVERY canonical data file from — attribute_definitions.json (raw schema), component_metadata.json (presentation metadata), screen_identity.json and binding_semantics.json. Shows layer (env / cwd / home / bundled), the absolute path actually read, file mtime and freshness per file, plus when this server process loaded them. Use it when the CLI and the MCP seem to disagree about the canon:
|
|
7
|
+
"Report where the MCP server loaded EVERY canonical data file from — attribute_definitions.json (raw schema), component_metadata.json (presentation metadata), screen_identity.json and binding_semantics.json. Shows layer (env / cwd / home / bundled), the absolute path actually read, file mtime and freshness per file, plus when this server process loaded them. Use it when the CLI and the MCP seem to disagree about the canon: since 2.12.0 every tool call re-reads a file whose CONTENT changed (sha256, not mtime), so a restart is not needed after a jsonui-cli distribution; `lastReload` says when that last happened and whether it succeeded, and `staleInMemory` is non-empty only when a reload FAILED (a file did not parse) and the server is still serving the previous content.",
|
|
8
8
|
{},
|
|
9
9
|
async () => {
|
|
10
10
|
const info = loader.getDataSource();
|
|
@@ -29,6 +29,7 @@ export function register(server: McpServer, loader: SpecLoader) {
|
|
|
29
29
|
// however current the file on disk looks.
|
|
30
30
|
const loadedAt = loader.getLoadedAt();
|
|
31
31
|
const changedSinceLoad = loader.getChangedSinceLoad();
|
|
32
|
+
const lastReload = loader.getLastReload() ?? undefined;
|
|
32
33
|
return {
|
|
33
34
|
content: [
|
|
34
35
|
{
|
|
@@ -45,6 +46,7 @@ export function register(server: McpServer, loader: SpecLoader) {
|
|
|
45
46
|
componentCount: info.componentCount,
|
|
46
47
|
commonAttributeCount: info.commonAttributeCount,
|
|
47
48
|
loadedAt,
|
|
49
|
+
lastReload,
|
|
48
50
|
staleInMemory: changedSinceLoad.length > 0 ? changedSinceLoad : undefined,
|
|
49
51
|
// `freshness` is a property of the FILE (how old it is);
|
|
50
52
|
// `staleInMemory` is a property of THIS SERVER (whether what
|
|
@@ -53,7 +55,7 @@ export function register(server: McpServer, loader: SpecLoader) {
|
|
|
53
55
|
// "fresh" on every file while the server serves something
|
|
54
56
|
// else. The hint names which one fired.
|
|
55
57
|
hint: changedSinceLoad.length
|
|
56
|
-
? "The CONTENT of these files differs from what this server loaded (compared by sha256, not by timestamp — a distribution moves every mtime without changing bytes). The server
|
|
58
|
+
? "The CONTENT of these files differs from what this server loaded (compared by sha256, not by timestamp — a distribution moves every mtime without changing bytes) AND the automatic reload did not replace it — see lastReload.error. The server keeps serving the last content that parsed; fix the file (or wait for the distribution to finish writing it) and call again."
|
|
57
59
|
: stale
|
|
58
60
|
? "At least one data file is > 90 days old. Re-fetch via `npm rebuild jui-tools-mcp-server` or point JSONUI_CLI_PATH at a fresher checkout."
|
|
59
61
|
: undefined,
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A jsonui-cli distribution replaces `~/.jsonui-cli` under every running MCP
|
|
3
|
+
* server. Until 2.12.0 the remedy was "restart the MCP server" — in EVERY
|
|
4
|
+
* session, after EVERY release (measured 2026-09-10: three running servers
|
|
5
|
+
* at 2.10.3 while the distributed copy was 2.11.1). The user's word for it:
|
|
6
|
+
* 面倒. So every tool call now asks `refreshIfChanged()` first; the check is
|
|
7
|
+
* seven sha256s of small files, ~0.2 ms.
|
|
8
|
+
*
|
|
9
|
+
* The arms below are the three outcomes a call can meet, plus the one
|
|
10
|
+
* structural claim ("every tool is wrapped") that a per-tool call would not
|
|
11
|
+
* give: (1) content changed → the call serves the new content; (2) same
|
|
12
|
+
* bytes, new mtime → nothing happens (a distribution moves every mtime);
|
|
13
|
+
* (3) a half-written file → the previous content is kept and the failure is
|
|
14
|
+
* reported, then the next call recovers once the file is whole.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, utimesSync, writeFileSync } from "fs";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
|
+
|
|
20
|
+
import { installAutoReload } from "../src/auto_reload.js";
|
|
21
|
+
import { SpecLoader } from "../src/spec_loader.js";
|
|
22
|
+
import { register as registerDataSource } from "../src/tools/spec/get_data_source.js";
|
|
23
|
+
import {
|
|
24
|
+
cleanupTempDirs,
|
|
25
|
+
createToolHarness,
|
|
26
|
+
makeCliDataset,
|
|
27
|
+
makeTempDir,
|
|
28
|
+
FIXTURE_ATTRIBUTE_DEFINITIONS,
|
|
29
|
+
FIXTURE_COVERAGE,
|
|
30
|
+
} from "./helpers.js";
|
|
31
|
+
|
|
32
|
+
const originalCwd = process.cwd();
|
|
33
|
+
let cliRoot: string;
|
|
34
|
+
let loader: SpecLoader;
|
|
35
|
+
|
|
36
|
+
function attrPath(): string {
|
|
37
|
+
return join(cliRoot, "shared", "core", "attribute_definitions.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One more component on disk than the fixture ships with. */
|
|
41
|
+
function addComponentOnDisk(): void {
|
|
42
|
+
writeFileSync(
|
|
43
|
+
attrPath(),
|
|
44
|
+
JSON.stringify({ ...FIXTURE_ATTRIBUTE_DEFINITIONS, Extra: { foo: { type: "string" } } }, null, 2)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function harnessWithDataSource() {
|
|
49
|
+
const harness = createToolHarness();
|
|
50
|
+
installAutoReload(harness.server, loader);
|
|
51
|
+
registerDataSource(harness.server, loader);
|
|
52
|
+
return harness;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
cliRoot = makeTempDir("cli");
|
|
57
|
+
makeCliDataset(cliRoot, { coverage: FIXTURE_COVERAGE });
|
|
58
|
+
process.env.JSONUI_CLI_PATH = cliRoot;
|
|
59
|
+
process.chdir(makeTempDir("cwd"));
|
|
60
|
+
process.env.HOME = makeTempDir("home");
|
|
61
|
+
process.env.USERPROFILE = process.env.HOME;
|
|
62
|
+
loader = new SpecLoader(makeTempDir("mcp"));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
delete process.env.JSONUI_CLI_PATH;
|
|
67
|
+
process.chdir(originalCwd);
|
|
68
|
+
cleanupTempDirs();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("a tool call after the canon changed serves the new content", () => {
|
|
72
|
+
it("the component added on disk is counted on the very next call", async () => {
|
|
73
|
+
const harness = harnessWithDataSource();
|
|
74
|
+
const before = JSON.parse(await harness.call("get_data_source"));
|
|
75
|
+
addComponentOnDisk();
|
|
76
|
+
const after = JSON.parse(await harness.call("get_data_source"));
|
|
77
|
+
|
|
78
|
+
expect(after.componentCount).toBe(before.componentCount + 1);
|
|
79
|
+
expect(loader.listComponents()).toContain("Extra");
|
|
80
|
+
expect(after.lastReload.ok).toBe(true);
|
|
81
|
+
expect(after.lastReload.changed).toContain(attrPath());
|
|
82
|
+
// Nothing left to report: the reload consumed the difference.
|
|
83
|
+
expect(after.staleInMemory).toBeUndefined();
|
|
84
|
+
expect(after.loadedAt > before.loadedAt).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("without the wrapper the same call serves the old content (陰性対照 on the mechanism)", async () => {
|
|
88
|
+
const harness = createToolHarness();
|
|
89
|
+
registerDataSource(harness.server, loader); // no installAutoReload
|
|
90
|
+
const before = JSON.parse(await harness.call("get_data_source"));
|
|
91
|
+
addComponentOnDisk();
|
|
92
|
+
const after = JSON.parse(await harness.call("get_data_source"));
|
|
93
|
+
expect(after.componentCount).toBe(before.componentCount);
|
|
94
|
+
expect(after.staleInMemory).toContain(attrPath());
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("same bytes with a new mtime is not a change", () => {
|
|
99
|
+
it("does not reload when only the timestamp moved", async () => {
|
|
100
|
+
const harness = harnessWithDataSource();
|
|
101
|
+
const before = JSON.parse(await harness.call("get_data_source"));
|
|
102
|
+
const bytes = readFileSync(attrPath());
|
|
103
|
+
writeFileSync(attrPath(), bytes);
|
|
104
|
+
const future = new Date(Date.now() + 60_000);
|
|
105
|
+
utimesSync(attrPath(), future, future);
|
|
106
|
+
const after = JSON.parse(await harness.call("get_data_source"));
|
|
107
|
+
expect(after.lastReload).toBeUndefined();
|
|
108
|
+
expect(after.loadedAt).toBe(before.loadedAt);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("a half-written file does not take the server down", () => {
|
|
113
|
+
it("keeps the previous content, reports the failure, and recovers on the next whole write", async () => {
|
|
114
|
+
const harness = harnessWithDataSource();
|
|
115
|
+
const before = JSON.parse(await harness.call("get_data_source"));
|
|
116
|
+
|
|
117
|
+
writeFileSync(attrPath(), '{"Button": {"text": {"ty'); // mid-copy
|
|
118
|
+
const during = JSON.parse(await harness.call("get_data_source"));
|
|
119
|
+
expect(during.componentCount).toBe(before.componentCount);
|
|
120
|
+
expect(during.lastReload.ok).toBe(false);
|
|
121
|
+
expect(during.lastReload.error).toMatch(/JSON/);
|
|
122
|
+
expect(during.staleInMemory).toContain(attrPath());
|
|
123
|
+
expect(during.hint).toMatch(/automatic reload did not replace it/);
|
|
124
|
+
|
|
125
|
+
addComponentOnDisk();
|
|
126
|
+
const after = JSON.parse(await harness.call("get_data_source"));
|
|
127
|
+
expect(after.componentCount).toBe(before.componentCount + 1);
|
|
128
|
+
expect(after.lastReload.ok).toBe(true);
|
|
129
|
+
expect(after.staleInMemory).toBeUndefined();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("every registered tool is wrapped, whatever overload registered it", () => {
|
|
134
|
+
it("calls refreshIfChanged before each handler registered through the 4-arg form", async () => {
|
|
135
|
+
const harness = createToolHarness();
|
|
136
|
+
installAutoReload(harness.server, loader);
|
|
137
|
+
const spy = vi.spyOn(loader, "refreshIfChanged");
|
|
138
|
+
harness.server.tool("four", "d", {}, async () => ({ content: [{ type: "text" as const, text: "ok" }] }));
|
|
139
|
+
expect(await harness.call("four")).toBe("ok");
|
|
140
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("wraps the LAST function argument whatever the overload, so (name, schema, cb) is covered too", async () => {
|
|
144
|
+
// The harness stores (name, description, schema, handler) positionally
|
|
145
|
+
// and cannot represent the SDK's shorter overloads, so the wrapper is
|
|
146
|
+
// checked here against a recorder: the registration must reach the
|
|
147
|
+
// original with the handler REPLACED in the position it was given.
|
|
148
|
+
const recorded: unknown[][] = [];
|
|
149
|
+
const fake = { tool: (...args: unknown[]) => recorded.push(args) } as unknown as Parameters<typeof installAutoReload>[0];
|
|
150
|
+
installAutoReload(fake, loader);
|
|
151
|
+
const spy = vi.spyOn(loader, "refreshIfChanged");
|
|
152
|
+
const handler = async () => "h";
|
|
153
|
+
(fake as unknown as { tool: (...a: unknown[]) => unknown }).tool("three", {}, handler);
|
|
154
|
+
(fake as unknown as { tool: (...a: unknown[]) => unknown }).tool("two", handler);
|
|
155
|
+
|
|
156
|
+
expect(recorded[0][0]).toBe("three");
|
|
157
|
+
expect(recorded[0][2]).not.toBe(handler);
|
|
158
|
+
expect(recorded[1][1]).not.toBe(handler);
|
|
159
|
+
await (recorded[0][2] as () => Promise<string>)();
|
|
160
|
+
await (recorded[1][1] as () => Promise<string>)();
|
|
161
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
162
|
+
// and the original handler still runs underneath
|
|
163
|
+
expect(await (recorded[0][2] as () => Promise<string>)()).toBe("h");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("the wrapper runs BEFORE the handler, not after (order is the point)", async () => {
|
|
167
|
+
const harness = createToolHarness();
|
|
168
|
+
installAutoReload(harness.server, loader);
|
|
169
|
+
harness.server.tool("count", "d", {}, async () => ({
|
|
170
|
+
content: [{ type: "text" as const, text: String(loader.getDataSource().componentCount) }],
|
|
171
|
+
}));
|
|
172
|
+
const before = Number(await harness.call("count"));
|
|
173
|
+
addComponentOnDisk();
|
|
174
|
+
expect(Number(await harness.call("count"))).toBe(before + 1);
|
|
175
|
+
});
|
|
176
|
+
});
|