@thehammer/answer-key-mcp-server 1.0.0 → 1.0.1
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/dist/api-client.d.ts +75 -0
- package/dist/api-client.js +136 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +62 -0
- package/dist/tools/_shared.d.ts +62 -0
- package/dist/tools/_shared.js +71 -0
- package/dist/tools/audit-tools.d.ts +19 -0
- package/dist/tools/audit-tools.js +221 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +19 -0
- package/package.json +1 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the gpt-manager answer-key MCP endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Every request carries the dispatch's Bearer token for
|
|
5
|
+
* `AgentDispatchTokenMiddleware`, whose route group pins `dispatchable_type` to
|
|
6
|
+
* `AnswerKey` — a token minted for any other kind of dispatch is rejected 403
|
|
7
|
+
* before a controller runs.
|
|
8
|
+
*
|
|
9
|
+
* This client is a PURE PROXY. It holds no audit logic of any kind: it does not
|
|
10
|
+
* decide a verdict, does not compare a value to anything, and never sees an
|
|
11
|
+
* expected value. All of that lives behind the three endpoints.
|
|
12
|
+
*/
|
|
13
|
+
declare const ANSWER_KEY_ID: string;
|
|
14
|
+
/**
|
|
15
|
+
* Raw API result — every tool in this package uses it.
|
|
16
|
+
*
|
|
17
|
+
* A 409 `lease_lost`, an empty queue, and a 422 confidence rejection are all
|
|
18
|
+
* things the AGENT has to act on: re-lease, stop, or resubmit. Collapsing them
|
|
19
|
+
* into a thrown Error would strip the structured body that says which.
|
|
20
|
+
*/
|
|
21
|
+
export interface RawApiResult {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
status: number;
|
|
24
|
+
body: unknown;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* SG-377: copied from `mcp-server/src/api-client.ts` (`apiRequestRaw`), which is
|
|
28
|
+
* module-private there and so cannot be imported even in principle.
|
|
29
|
+
*
|
|
30
|
+
* The shape test for extracting a shared core is ALREADY MET and this copies
|
|
31
|
+
* anyway — a recorded debt, not a deferral. It is one mechanism end to end (URL
|
|
32
|
+
* join, AbortController against a 30s default, the Bearer/Content-Type/Accept
|
|
33
|
+
* header set, JSON.stringify on non-GET, parse-or-null, the {ok, status, body}
|
|
34
|
+
* return, one AbortError -> timeout arm), differing only in the two module
|
|
35
|
+
* constants `SCHEMA_*` vs `AUDIT_*`.
|
|
36
|
+
*
|
|
37
|
+
* Two blast-radius reasons, neither of them a headcount: (1) the extraction edits
|
|
38
|
+
* an ALREADY-PUBLISHED package, which means the exact `make publish-mcp` version
|
|
39
|
+
* bump and `~/.npm/_npx` cache purge this separate package exists to keep away
|
|
40
|
+
* from an audit release — the schema server's own source records SG-171, a
|
|
41
|
+
* concurrent publish turning into `MCP error -32000: Connection closed` inside a
|
|
42
|
+
* live dispatch; (2) `npx -y` re-resolves the dependency graph at every dispatch,
|
|
43
|
+
* so a shared core inherits that hazard unless the pin is decided, and
|
|
44
|
+
* exact-pinned-published versus vendored-at-build-time is a real design decision
|
|
45
|
+
* with a real failure mode.
|
|
46
|
+
*
|
|
47
|
+
* REPAYMENT TRIGGER, concrete: extract at the NEXT `make publish-mcp` that is
|
|
48
|
+
* happening for an unrelated reason. At that moment both objections stop
|
|
49
|
+
* applying — the version bump and the cache purge are already being accepted, so
|
|
50
|
+
* the extraction rides along at zero additional hazard. The trigger says WHEN,
|
|
51
|
+
* not WHAT: the pin decision is still owed at extraction time. Do not re-derive
|
|
52
|
+
* "wait until there are N servers" — that argument is retired.
|
|
53
|
+
*/
|
|
54
|
+
export declare function apiRequestRaw(method: string, path: string, body?: Record<string, unknown>, timeoutMs?: number): Promise<RawApiResult>;
|
|
55
|
+
/** The answer-key-scoped MCP route prefix. */
|
|
56
|
+
export declare function mcpPath(endpoint: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* The three endpoints, as an interface so the tool modules can be driven by a
|
|
59
|
+
* fake in tests without touching the network.
|
|
60
|
+
*/
|
|
61
|
+
export interface AuditHttpClient {
|
|
62
|
+
input(filters: {
|
|
63
|
+
format?: "manifest" | "image" | "text" | "both";
|
|
64
|
+
file_id?: string;
|
|
65
|
+
page_start?: number;
|
|
66
|
+
page_end?: number;
|
|
67
|
+
}): Promise<RawApiResult>;
|
|
68
|
+
leaseUnaudited(payload: {
|
|
69
|
+
limit?: number;
|
|
70
|
+
message?: string;
|
|
71
|
+
}): Promise<RawApiResult>;
|
|
72
|
+
storeResult(payload: Record<string, unknown>): Promise<RawApiResult>;
|
|
73
|
+
}
|
|
74
|
+
export declare const auditHttpClient: AuditHttpClient;
|
|
75
|
+
export { ANSWER_KEY_ID };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the gpt-manager answer-key MCP endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Every request carries the dispatch's Bearer token for
|
|
5
|
+
* `AgentDispatchTokenMiddleware`, whose route group pins `dispatchable_type` to
|
|
6
|
+
* `AnswerKey` — a token minted for any other kind of dispatch is rejected 403
|
|
7
|
+
* before a controller runs.
|
|
8
|
+
*
|
|
9
|
+
* This client is a PURE PROXY. It holds no audit logic of any kind: it does not
|
|
10
|
+
* decide a verdict, does not compare a value to anything, and never sees an
|
|
11
|
+
* expected value. All of that lives behind the three endpoints.
|
|
12
|
+
*/
|
|
13
|
+
const API_URL = (process.env.AUDIT_API_URL ?? "").replace(/\/+$/, "");
|
|
14
|
+
const API_TOKEN = process.env.AUDIT_API_TOKEN ?? "";
|
|
15
|
+
const ANSWER_KEY_ID = process.env.ANSWER_KEY_ID ?? "";
|
|
16
|
+
/**
|
|
17
|
+
* Boot gates — all three hard-exit when missing.
|
|
18
|
+
*
|
|
19
|
+
* There is deliberately NO fallback base URL. The schema server hard-codes a
|
|
20
|
+
* loopback default and that is the anti-pattern: a silently-defaulted URL
|
|
21
|
+
* turns a misconfigured dispatch into a confusing connection-refused cascade at
|
|
22
|
+
* first tool call instead of one clear line in the worker's stderr at boot. The
|
|
23
|
+
* Laravel side already treats a silent URL default as a bug
|
|
24
|
+
* (`AgentDispatchLaunchService::resolveCallbackUrls()` throws rather than
|
|
25
|
+
* defaulting), and this is the same decision on the other end of the same wire.
|
|
26
|
+
*
|
|
27
|
+
* There is no `MAX_RUNTIME_MS` gate either: nothing here reads one, and all
|
|
28
|
+
* three tools are one-shot with no poll budget to size.
|
|
29
|
+
*/
|
|
30
|
+
for (const [name, value] of [
|
|
31
|
+
["AUDIT_API_URL", API_URL],
|
|
32
|
+
["AUDIT_API_TOKEN", API_TOKEN],
|
|
33
|
+
["ANSWER_KEY_ID", ANSWER_KEY_ID],
|
|
34
|
+
]) {
|
|
35
|
+
if (!value) {
|
|
36
|
+
console.error(`${name} is required`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Default timeout for API requests. */
|
|
41
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
42
|
+
/**
|
|
43
|
+
* SG-377: copied from `mcp-server/src/api-client.ts` (`apiRequestRaw`), which is
|
|
44
|
+
* module-private there and so cannot be imported even in principle.
|
|
45
|
+
*
|
|
46
|
+
* The shape test for extracting a shared core is ALREADY MET and this copies
|
|
47
|
+
* anyway — a recorded debt, not a deferral. It is one mechanism end to end (URL
|
|
48
|
+
* join, AbortController against a 30s default, the Bearer/Content-Type/Accept
|
|
49
|
+
* header set, JSON.stringify on non-GET, parse-or-null, the {ok, status, body}
|
|
50
|
+
* return, one AbortError -> timeout arm), differing only in the two module
|
|
51
|
+
* constants `SCHEMA_*` vs `AUDIT_*`.
|
|
52
|
+
*
|
|
53
|
+
* Two blast-radius reasons, neither of them a headcount: (1) the extraction edits
|
|
54
|
+
* an ALREADY-PUBLISHED package, which means the exact `make publish-mcp` version
|
|
55
|
+
* bump and `~/.npm/_npx` cache purge this separate package exists to keep away
|
|
56
|
+
* from an audit release — the schema server's own source records SG-171, a
|
|
57
|
+
* concurrent publish turning into `MCP error -32000: Connection closed` inside a
|
|
58
|
+
* live dispatch; (2) `npx -y` re-resolves the dependency graph at every dispatch,
|
|
59
|
+
* so a shared core inherits that hazard unless the pin is decided, and
|
|
60
|
+
* exact-pinned-published versus vendored-at-build-time is a real design decision
|
|
61
|
+
* with a real failure mode.
|
|
62
|
+
*
|
|
63
|
+
* REPAYMENT TRIGGER, concrete: extract at the NEXT `make publish-mcp` that is
|
|
64
|
+
* happening for an unrelated reason. At that moment both objections stop
|
|
65
|
+
* applying — the version bump and the cache purge are already being accepted, so
|
|
66
|
+
* the extraction rides along at zero additional hazard. The trigger says WHEN,
|
|
67
|
+
* not WHAT: the pin decision is still owed at extraction time. Do not re-derive
|
|
68
|
+
* "wait until there are N servers" — that argument is retired.
|
|
69
|
+
*/
|
|
70
|
+
export async function apiRequestRaw(method, path, body, timeoutMs) {
|
|
71
|
+
const url = `${API_URL}/api/${path}`;
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const resolvedTimeout = timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
74
|
+
const timer = setTimeout(() => controller.abort(), resolvedTimeout);
|
|
75
|
+
const options = {
|
|
76
|
+
method,
|
|
77
|
+
signal: controller.signal,
|
|
78
|
+
headers: {
|
|
79
|
+
Authorization: `Bearer ${API_TOKEN}`,
|
|
80
|
+
"Content-Type": "application/json",
|
|
81
|
+
Accept: "application/json",
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
if (body !== undefined && method !== "GET") {
|
|
85
|
+
options.body = JSON.stringify(body);
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const response = await fetch(url, options);
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = await response.json();
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
parsed = null;
|
|
95
|
+
}
|
|
96
|
+
return { ok: response.ok, status: response.status, body: parsed };
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
100
|
+
throw new Error(`API timeout: ${method} ${path} did not respond within ${resolvedTimeout / 1000}s`);
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** The answer-key-scoped MCP route prefix. */
|
|
109
|
+
export function mcpPath(endpoint) {
|
|
110
|
+
return `answer-keys/mcp/${ANSWER_KEY_ID}/${endpoint}`;
|
|
111
|
+
}
|
|
112
|
+
export const auditHttpClient = {
|
|
113
|
+
input(filters) {
|
|
114
|
+
const qs = [];
|
|
115
|
+
for (const key of [
|
|
116
|
+
"format",
|
|
117
|
+
"file_id",
|
|
118
|
+
"page_start",
|
|
119
|
+
"page_end",
|
|
120
|
+
]) {
|
|
121
|
+
const value = filters[key];
|
|
122
|
+
if (value !== undefined) {
|
|
123
|
+
qs.push(`${key}=${encodeURIComponent(String(value))}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const suffix = qs.length > 0 ? `?${qs.join("&")}` : "";
|
|
127
|
+
return apiRequestRaw("GET", mcpPath(`input${suffix}`));
|
|
128
|
+
},
|
|
129
|
+
leaseUnaudited(payload) {
|
|
130
|
+
return apiRequestRaw("POST", mcpPath("unaudited"), payload);
|
|
131
|
+
},
|
|
132
|
+
storeResult(payload) {
|
|
133
|
+
return apiRequestRaw("POST", mcpPath("results"), payload);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
export { ANSWER_KEY_ID };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Answer Key MCP Server
|
|
4
|
+
*
|
|
5
|
+
* A stdio MCP server giving the blind answer-key audit agent exactly three
|
|
6
|
+
* tools, each a pure proxy over one gpt-manager endpoint:
|
|
7
|
+
*
|
|
8
|
+
* get_input — the source documents: a manifest, page text, or page images
|
|
9
|
+
* get_unaudited_answers — lease a batch of entries to audit
|
|
10
|
+
* store_result — record what the agent found for one entry
|
|
11
|
+
*
|
|
12
|
+
* ## Why this is its own package rather than three tools on the schema server
|
|
13
|
+
*
|
|
14
|
+
* Blast radius. `make publish-mcp` bumps a version that every in-flight schema
|
|
15
|
+
* dispatch's `npx` resolves, and purges the `~/.npm/_npx` cache; the schema
|
|
16
|
+
* server's own source records SG-171, where exactly that turned into
|
|
17
|
+
* `MCP error -32000: Connection closed` inside a live dispatch. An audit release
|
|
18
|
+
* must not be able to do that to a schema build.
|
|
19
|
+
*
|
|
20
|
+
* And the schema server is schema-shaped at MODULE level, not merely at its gate:
|
|
21
|
+
* its api-client binds URL/token/id to `SCHEMA_*` env names, its `mcpPath()`
|
|
22
|
+
* hardcodes the `schemas/mcp/{id}` prefix, and its index registers every schema
|
|
23
|
+
* tool unconditionally — so an audit dispatch there would carry ~20 tools that
|
|
24
|
+
* 404 against an empty `SCHEMA_ID`.
|
|
25
|
+
*
|
|
26
|
+
* ## The audit is BLIND
|
|
27
|
+
*
|
|
28
|
+
* The agent is never sent the recorded answer, in any match mode, and this
|
|
29
|
+
* package's obligation on that is DOCUMENTATION, not enforcement — the
|
|
30
|
+
* withholding itself lives server-side. Every tool description says plainly that
|
|
31
|
+
* no result ever carries an expected value, so the agent reads its absence as the
|
|
32
|
+
* normal shape of the data rather than as a fault to report.
|
|
33
|
+
*
|
|
34
|
+
* Environment variables — all three are required and the server hard-exits
|
|
35
|
+
* without them (no silent URL default; see api-client.ts):
|
|
36
|
+
* AUDIT_API_URL — gpt-manager base URL the worker can reach
|
|
37
|
+
* AUDIT_API_TOKEN — the dispatch's Bearer token
|
|
38
|
+
* ANSWER_KEY_ID — the key being audited
|
|
39
|
+
*
|
|
40
|
+
* There is no MAX_RUNTIME_MS: nothing reads one and all three tools are one-shot.
|
|
41
|
+
* There is no DANX_SANDBOX_PATH either — this agent has no disk at all, which is
|
|
42
|
+
* why `get_input` fetches page images itself and returns them as content blocks.
|
|
43
|
+
*/
|
|
44
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Answer Key MCP Server
|
|
4
|
+
*
|
|
5
|
+
* A stdio MCP server giving the blind answer-key audit agent exactly three
|
|
6
|
+
* tools, each a pure proxy over one gpt-manager endpoint:
|
|
7
|
+
*
|
|
8
|
+
* get_input — the source documents: a manifest, page text, or page images
|
|
9
|
+
* get_unaudited_answers — lease a batch of entries to audit
|
|
10
|
+
* store_result — record what the agent found for one entry
|
|
11
|
+
*
|
|
12
|
+
* ## Why this is its own package rather than three tools on the schema server
|
|
13
|
+
*
|
|
14
|
+
* Blast radius. `make publish-mcp` bumps a version that every in-flight schema
|
|
15
|
+
* dispatch's `npx` resolves, and purges the `~/.npm/_npx` cache; the schema
|
|
16
|
+
* server's own source records SG-171, where exactly that turned into
|
|
17
|
+
* `MCP error -32000: Connection closed` inside a live dispatch. An audit release
|
|
18
|
+
* must not be able to do that to a schema build.
|
|
19
|
+
*
|
|
20
|
+
* And the schema server is schema-shaped at MODULE level, not merely at its gate:
|
|
21
|
+
* its api-client binds URL/token/id to `SCHEMA_*` env names, its `mcpPath()`
|
|
22
|
+
* hardcodes the `schemas/mcp/{id}` prefix, and its index registers every schema
|
|
23
|
+
* tool unconditionally — so an audit dispatch there would carry ~20 tools that
|
|
24
|
+
* 404 against an empty `SCHEMA_ID`.
|
|
25
|
+
*
|
|
26
|
+
* ## The audit is BLIND
|
|
27
|
+
*
|
|
28
|
+
* The agent is never sent the recorded answer, in any match mode, and this
|
|
29
|
+
* package's obligation on that is DOCUMENTATION, not enforcement — the
|
|
30
|
+
* withholding itself lives server-side. Every tool description says plainly that
|
|
31
|
+
* no result ever carries an expected value, so the agent reads its absence as the
|
|
32
|
+
* normal shape of the data rather than as a fault to report.
|
|
33
|
+
*
|
|
34
|
+
* Environment variables — all three are required and the server hard-exits
|
|
35
|
+
* without them (no silent URL default; see api-client.ts):
|
|
36
|
+
* AUDIT_API_URL — gpt-manager base URL the worker can reach
|
|
37
|
+
* AUDIT_API_TOKEN — the dispatch's Bearer token
|
|
38
|
+
* ANSWER_KEY_ID — the key being audited
|
|
39
|
+
*
|
|
40
|
+
* There is no MAX_RUNTIME_MS: nothing reads one and all three tools are one-shot.
|
|
41
|
+
* There is no DANX_SANDBOX_PATH either — this agent has no disk at all, which is
|
|
42
|
+
* why `get_input` fetches page images itself and returns them as content blocks.
|
|
43
|
+
*/
|
|
44
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
45
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
46
|
+
import { auditHttpClient } from "./api-client.js";
|
|
47
|
+
import { registerAuditTools } from "./tools/audit-tools.js";
|
|
48
|
+
import { LOADED_VERSION } from "./version.js";
|
|
49
|
+
const server = new McpServer({
|
|
50
|
+
name: "answer-key-mcp-server",
|
|
51
|
+
version: LOADED_VERSION,
|
|
52
|
+
});
|
|
53
|
+
registerAuditTools(server, auditHttpClient);
|
|
54
|
+
async function main() {
|
|
55
|
+
const transport = new StdioServerTransport();
|
|
56
|
+
await server.connect(transport);
|
|
57
|
+
console.error(`Answer Key MCP Server ${LOADED_VERSION} running on stdio`);
|
|
58
|
+
}
|
|
59
|
+
main().catch((error) => {
|
|
60
|
+
console.error("Fatal error:", error);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for this package's three tool modules.
|
|
3
|
+
*
|
|
4
|
+
* All three are SG-377 copies of `mcp-server/src/tools/_shared.ts`. See the long
|
|
5
|
+
* note on `apiRequestRaw` in `../api-client.ts` for the met shape test, the two
|
|
6
|
+
* blast-radius reasons this package copies anyway, and the concrete repayment
|
|
7
|
+
* trigger. Each is repeated here in short because the debt has to be met in the
|
|
8
|
+
* code by whoever next publishes, not in a card they would have to remember.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
/**
|
|
12
|
+
* SG-377: copied from `mcp-server/src/tools/_shared.ts` (`jsonResult`), which is
|
|
13
|
+
* identical there — the shape test is met and this copies anyway. Blast radius:
|
|
14
|
+
* extracting it edits an already-published package, forcing the `make publish-mcp`
|
|
15
|
+
* bump + `~/.npm/_npx` purge this package exists to keep away from an audit
|
|
16
|
+
* release; and `npx -y` re-resolves at every dispatch, so a shared core inherits
|
|
17
|
+
* that hazard until the pin is decided. REPAYMENT TRIGGER: extract at the NEXT
|
|
18
|
+
* `make publish-mcp` happening for an unrelated reason, when the bump and the
|
|
19
|
+
* purge are already being accepted.
|
|
20
|
+
*
|
|
21
|
+
* Wrap a response body into the MCP tool result format.
|
|
22
|
+
*/
|
|
23
|
+
export declare function jsonResult(data: unknown): {
|
|
24
|
+
content: {
|
|
25
|
+
type: "text";
|
|
26
|
+
text: string;
|
|
27
|
+
}[];
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* SG-377: copied from `mcp-server/src/tools/_shared.ts` (`messageParam`), which is
|
|
31
|
+
* identical there — the shape test is met and this copies anyway. Blast radius:
|
|
32
|
+
* extracting it edits an already-published package, forcing the `make publish-mcp`
|
|
33
|
+
* bump + `~/.npm/_npx` purge this package exists to keep away from an audit
|
|
34
|
+
* release; and `npx -y` re-resolves at every dispatch, so a shared core inherits
|
|
35
|
+
* that hazard until the pin is decided. REPAYMENT TRIGGER: extract at the NEXT
|
|
36
|
+
* `make publish-mcp` happening for an unrelated reason, when the bump and the
|
|
37
|
+
* purge are already being accepted.
|
|
38
|
+
*/
|
|
39
|
+
export declare const messageParam: z.ZodOptional<z.ZodString>;
|
|
40
|
+
/**
|
|
41
|
+
* SG-377: copied from `mcp-server/src/tools/_shared.ts` (`idParam`), which is
|
|
42
|
+
* identical there — the shape test is met and this copies anyway. Blast radius:
|
|
43
|
+
* extracting it edits an already-published package, forcing the `make publish-mcp`
|
|
44
|
+
* bump + `~/.npm/_npx` purge this package exists to keep away from an audit
|
|
45
|
+
* release; and `npx -y` re-resolves at every dispatch, so a shared core inherits
|
|
46
|
+
* that hazard until the pin is decided. REPAYMENT TRIGGER: extract at the NEXT
|
|
47
|
+
* `make publish-mcp` happening for an unrelated reason, when the bump and the
|
|
48
|
+
* purge are already being accepted.
|
|
49
|
+
*
|
|
50
|
+
* `z.coerce.number()` because agents pattern-match the id shape off list
|
|
51
|
+
* responses and frequently send stringified numbers.
|
|
52
|
+
*
|
|
53
|
+
* `.int().positive()` is load-bearing rather than decorative: zod's coerce passes
|
|
54
|
+
* `null` and `""` through to `Number()`, which produces `0` — never a valid
|
|
55
|
+
* primary key. The endpoint validates `entry_id` as `required|integer` with no
|
|
56
|
+
* minimum, so a `0` would sail through validation and 404 deep inside the
|
|
57
|
+
* dispatch as `entry_unknown` instead of failing at the boundary with a legible
|
|
58
|
+
* parameter error.
|
|
59
|
+
*/
|
|
60
|
+
export declare function idParam(description: string): z.ZodCoercedNumber<unknown>;
|
|
61
|
+
/** A coerced, positive, optional integer — used for `limit` and the page window. */
|
|
62
|
+
export declare function optionalPositiveInt(description: string): z.ZodOptional<z.ZodCoercedNumber<unknown>>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for this package's three tool modules.
|
|
3
|
+
*
|
|
4
|
+
* All three are SG-377 copies of `mcp-server/src/tools/_shared.ts`. See the long
|
|
5
|
+
* note on `apiRequestRaw` in `../api-client.ts` for the met shape test, the two
|
|
6
|
+
* blast-radius reasons this package copies anyway, and the concrete repayment
|
|
7
|
+
* trigger. Each is repeated here in short because the debt has to be met in the
|
|
8
|
+
* code by whoever next publishes, not in a card they would have to remember.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
/**
|
|
12
|
+
* SG-377: copied from `mcp-server/src/tools/_shared.ts` (`jsonResult`), which is
|
|
13
|
+
* identical there — the shape test is met and this copies anyway. Blast radius:
|
|
14
|
+
* extracting it edits an already-published package, forcing the `make publish-mcp`
|
|
15
|
+
* bump + `~/.npm/_npx` purge this package exists to keep away from an audit
|
|
16
|
+
* release; and `npx -y` re-resolves at every dispatch, so a shared core inherits
|
|
17
|
+
* that hazard until the pin is decided. REPAYMENT TRIGGER: extract at the NEXT
|
|
18
|
+
* `make publish-mcp` happening for an unrelated reason, when the bump and the
|
|
19
|
+
* purge are already being accepted.
|
|
20
|
+
*
|
|
21
|
+
* Wrap a response body into the MCP tool result format.
|
|
22
|
+
*/
|
|
23
|
+
export function jsonResult(data) {
|
|
24
|
+
return {
|
|
25
|
+
content: [
|
|
26
|
+
{ type: "text", text: JSON.stringify(data, null, 2) },
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* SG-377: copied from `mcp-server/src/tools/_shared.ts` (`messageParam`), which is
|
|
32
|
+
* identical there — the shape test is met and this copies anyway. Blast radius:
|
|
33
|
+
* extracting it edits an already-published package, forcing the `make publish-mcp`
|
|
34
|
+
* bump + `~/.npm/_npx` purge this package exists to keep away from an audit
|
|
35
|
+
* release; and `npx -y` re-resolves at every dispatch, so a shared core inherits
|
|
36
|
+
* that hazard until the pin is decided. REPAYMENT TRIGGER: extract at the NEXT
|
|
37
|
+
* `make publish-mcp` happening for an unrelated reason, when the bump and the
|
|
38
|
+
* purge are already being accepted.
|
|
39
|
+
*/
|
|
40
|
+
export const messageParam = z
|
|
41
|
+
.string()
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("Human-readable description of what you're doing (shown in the user's activity feed). " +
|
|
44
|
+
"Never put page text or quoted source text here — these are patient records.");
|
|
45
|
+
/**
|
|
46
|
+
* SG-377: copied from `mcp-server/src/tools/_shared.ts` (`idParam`), which is
|
|
47
|
+
* identical there — the shape test is met and this copies anyway. Blast radius:
|
|
48
|
+
* extracting it edits an already-published package, forcing the `make publish-mcp`
|
|
49
|
+
* bump + `~/.npm/_npx` purge this package exists to keep away from an audit
|
|
50
|
+
* release; and `npx -y` re-resolves at every dispatch, so a shared core inherits
|
|
51
|
+
* that hazard until the pin is decided. REPAYMENT TRIGGER: extract at the NEXT
|
|
52
|
+
* `make publish-mcp` happening for an unrelated reason, when the bump and the
|
|
53
|
+
* purge are already being accepted.
|
|
54
|
+
*
|
|
55
|
+
* `z.coerce.number()` because agents pattern-match the id shape off list
|
|
56
|
+
* responses and frequently send stringified numbers.
|
|
57
|
+
*
|
|
58
|
+
* `.int().positive()` is load-bearing rather than decorative: zod's coerce passes
|
|
59
|
+
* `null` and `""` through to `Number()`, which produces `0` — never a valid
|
|
60
|
+
* primary key. The endpoint validates `entry_id` as `required|integer` with no
|
|
61
|
+
* minimum, so a `0` would sail through validation and 404 deep inside the
|
|
62
|
+
* dispatch as `entry_unknown` instead of failing at the boundary with a legible
|
|
63
|
+
* parameter error.
|
|
64
|
+
*/
|
|
65
|
+
export function idParam(description) {
|
|
66
|
+
return z.coerce.number().int().positive().describe(description);
|
|
67
|
+
}
|
|
68
|
+
/** A coerced, positive, optional integer — used for `limit` and the page window. */
|
|
69
|
+
export function optionalPositiveInt(description) {
|
|
70
|
+
return z.coerce.number().int().positive().optional().describe(description);
|
|
71
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The audit agent's entire tool surface: `get_input`, `get_unaudited_answers`,
|
|
3
|
+
* `store_result`.
|
|
4
|
+
*
|
|
5
|
+
* All three are pure proxies — every decision lives behind the gpt-manager
|
|
6
|
+
* endpoints. The one thing this module does on its own is fetch page image bytes
|
|
7
|
+
* (see `get_input`), because the agent has no disk to stage them on.
|
|
8
|
+
*/
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import type { AuditHttpClient } from "../api-client.js";
|
|
11
|
+
/**
|
|
12
|
+
* How many page images one `get_input` call may return.
|
|
13
|
+
*
|
|
14
|
+
* One MCP tool result is a single model turn, so an unbounded image window is a
|
|
15
|
+
* context blowout rather than a slow response. Text pages keep the server's own
|
|
16
|
+
* 20-page cap.
|
|
17
|
+
*/
|
|
18
|
+
export declare const IMAGE_PAGES_PER_CALL = 4;
|
|
19
|
+
export declare function registerAuditTools(server: McpServer, client: AuditHttpClient, fetchImpl?: typeof fetch): void;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The audit agent's entire tool surface: `get_input`, `get_unaudited_answers`,
|
|
3
|
+
* `store_result`.
|
|
4
|
+
*
|
|
5
|
+
* All three are pure proxies — every decision lives behind the gpt-manager
|
|
6
|
+
* endpoints. The one thing this module does on its own is fetch page image bytes
|
|
7
|
+
* (see `get_input`), because the agent has no disk to stage them on.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { idParam, jsonResult, messageParam, optionalPositiveInt } from "./_shared.js";
|
|
11
|
+
/**
|
|
12
|
+
* How many page images one `get_input` call may return.
|
|
13
|
+
*
|
|
14
|
+
* One MCP tool result is a single model turn, so an unbounded image window is a
|
|
15
|
+
* context blowout rather than a slow response. Text pages keep the server's own
|
|
16
|
+
* 20-page cap.
|
|
17
|
+
*/
|
|
18
|
+
export const IMAGE_PAGES_PER_CALL = 4;
|
|
19
|
+
/**
|
|
20
|
+
* Fetch one page image and return it as an MCP image content block.
|
|
21
|
+
*
|
|
22
|
+
* The block SHAPE is copied from the schema server's `_shared.ts`. Its
|
|
23
|
+
* `multiPageImageResult` helper is deliberately NOT imported: it has zero callers
|
|
24
|
+
* there and is dead, and it expects inline `images_base64` on each page — which
|
|
25
|
+
* this endpoint never returns, because gpt-manager hands back URLs only.
|
|
26
|
+
*
|
|
27
|
+
* This does not widen the SG-320 boundary. gpt-manager returns URLs; the shim
|
|
28
|
+
* fetches storage directly; the bytes move between this process and the model and
|
|
29
|
+
* never through gpt-manager.
|
|
30
|
+
*/
|
|
31
|
+
async function fetchImageBlock(page, fetchImpl) {
|
|
32
|
+
if (!page.url) {
|
|
33
|
+
return {
|
|
34
|
+
type: "text",
|
|
35
|
+
text: `Page ${page.page} of ${page.file_id} has no image url.`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const response = await fetchImpl(page.url);
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
return {
|
|
41
|
+
type: "text",
|
|
42
|
+
text: `Page ${page.page} of ${page.file_id} could not be fetched (HTTP ${response.status}).`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
46
|
+
return {
|
|
47
|
+
type: "image",
|
|
48
|
+
data: buffer.toString("base64"),
|
|
49
|
+
mimeType: page.mime || "image/jpeg",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function registerAuditTools(server, client, fetchImpl = fetch) {
|
|
53
|
+
server.tool("get_input", "Read the source documents this answer key was built from. " +
|
|
54
|
+
"Call this FIRST with the default 'manifest' format to see what documents exist and how many pages each has; " +
|
|
55
|
+
"then request windows of pages.\n\n" +
|
|
56
|
+
"format=manifest returns one descriptor per document {file_id, filename, mime, size, url, page_count, is_transcoding, text_transcode_names}.\n" +
|
|
57
|
+
"format=text returns page text (server caps a window at 20 pages).\n" +
|
|
58
|
+
`format=image returns page IMAGES as content blocks, at most ${IMAGE_PAGES_PER_CALL} per call — ` +
|
|
59
|
+
"a wider window is refused before any image is fetched, with {error: 'window_too_large', max, returned}; narrow the window and iterate.\n" +
|
|
60
|
+
"format=both returns text and images together under the same image cap.\n\n" +
|
|
61
|
+
"Never copy page text into a `message` or a `reason` — these are patient records.", {
|
|
62
|
+
format: z
|
|
63
|
+
.enum(["manifest", "image", "text", "both"])
|
|
64
|
+
.optional()
|
|
65
|
+
.describe("manifest (default) lists the documents; image/text/both return pages"),
|
|
66
|
+
file_id: z
|
|
67
|
+
.string()
|
|
68
|
+
.optional()
|
|
69
|
+
.describe("Restrict to one document. This is a UUID STRING as returned by the manifest, not a numeric id."),
|
|
70
|
+
page_start: optionalPositiveInt("First page of the window (1-based, inclusive)"),
|
|
71
|
+
page_end: optionalPositiveInt("Last page of the window (1-based, inclusive)"),
|
|
72
|
+
}, async ({ format, file_id, page_start, page_end }) => {
|
|
73
|
+
const result = await client.input({
|
|
74
|
+
format,
|
|
75
|
+
file_id,
|
|
76
|
+
page_start,
|
|
77
|
+
page_end,
|
|
78
|
+
});
|
|
79
|
+
if (!result.ok) {
|
|
80
|
+
return jsonResult(result.body);
|
|
81
|
+
}
|
|
82
|
+
const wantsImages = format === "image" || format === "both";
|
|
83
|
+
if (!wantsImages) {
|
|
84
|
+
return jsonResult(result.body);
|
|
85
|
+
}
|
|
86
|
+
const body = (result.body ?? {});
|
|
87
|
+
const pages = (body.pages ?? []);
|
|
88
|
+
const imagePages = pages.filter((page) => page.url !== undefined);
|
|
89
|
+
// The cap is decided from the DESCRIPTOR COUNT the endpoint just
|
|
90
|
+
// returned — after the URL-only call, which is cheap, and BEFORE a
|
|
91
|
+
// single image byte is fetched. A pre-request check on
|
|
92
|
+
// page_start/page_end cannot work: both are optional server-side and
|
|
93
|
+
// the server silently caps the window at 20, so the number of pages
|
|
94
|
+
// actually coming back is not knowable until it answers.
|
|
95
|
+
if (imagePages.length > IMAGE_PAGES_PER_CALL) {
|
|
96
|
+
return jsonResult({
|
|
97
|
+
error: "window_too_large",
|
|
98
|
+
max: IMAGE_PAGES_PER_CALL,
|
|
99
|
+
returned: imagePages.length,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const content = [];
|
|
103
|
+
for (const page of imagePages) {
|
|
104
|
+
content.push({
|
|
105
|
+
type: "text",
|
|
106
|
+
text: `--- ${page.file_id} page ${page.page} ---`,
|
|
107
|
+
});
|
|
108
|
+
content.push(await fetchImageBlock(page, fetchImpl));
|
|
109
|
+
}
|
|
110
|
+
const withoutUrls = pages.map((page) => {
|
|
111
|
+
const clean = { ...page };
|
|
112
|
+
delete clean.url;
|
|
113
|
+
return clean;
|
|
114
|
+
});
|
|
115
|
+
content.push({
|
|
116
|
+
type: "text",
|
|
117
|
+
text: JSON.stringify({ ...body, pages: withoutUrls }, null, 2),
|
|
118
|
+
});
|
|
119
|
+
return { content };
|
|
120
|
+
});
|
|
121
|
+
server.tool("get_unaudited_answers", "Lease a batch of entries to audit. Each entry is a QUESTION about one record in the documents:\n" +
|
|
122
|
+
"{entry_id, object_type, identity, identity_display, field_path, occurrence_idx, match_mode, attempts}, " +
|
|
123
|
+
"plus {remaining_count, lease_expires_at} on the batch.\n\n" +
|
|
124
|
+
"THE RESULT NEVER CARRIES AN EXPECTED VALUE. Not in any match mode — not exact, numeric, normalized, " +
|
|
125
|
+
"contains, regex, not_null, count, each_item, parentage, and not absent, must_not_contain, if_present or " +
|
|
126
|
+
"one_of either. It also never carries quoted source text or the pages the value was recorded on. " +
|
|
127
|
+
"That absence is the NORMAL shape of every entry you will ever see: it is not missing, not corrupt, " +
|
|
128
|
+
"not a special mode, and never something to report as a fault, skip an entry over, or ask for. " +
|
|
129
|
+
"You derive the value from the documents; the server holds the recorded answer and decides agreement itself.\n\n" +
|
|
130
|
+
"`match_mode` tells you the KIND of question, never the answer. exact/numeric/normalized/contains/regex: " +
|
|
131
|
+
"find the value the record states. count/each_item: report the list you find. not_null: report the value " +
|
|
132
|
+
"if the record states one. absent: say whether anything is there at all. must_not_contain: report what the " +
|
|
133
|
+
"record says (the forbidden text is not shown to you and you do not need it). if_present/one_of: report the " +
|
|
134
|
+
"value you find (the accepted set is not shown to you and you do not need it). parentage: report the parent " +
|
|
135
|
+
"identity you resolve.\n\n" +
|
|
136
|
+
"THE ONE THING THE PAYLOAD CANNOT WITHHOLD is `identity` and `identity_display` — your only handle on the " +
|
|
137
|
+
"record you are being asked about. For some entries the field being GRADED is that identity, so the identity " +
|
|
138
|
+
"handed to you is also the recorded answer, and you are never told which entries those are (the result carries " +
|
|
139
|
+
"no flag for it, deliberately, so every entry looks alike). It happens in three ways: the graded field_path is " +
|
|
140
|
+
"'name', so the anchor's primary IS the answer; the graded field_path is a key of the identity's secondary map, " +
|
|
141
|
+
"so the accepted forms sit in the payload under that key; or the entry's anchor is a KEYWORD anchor, whose " +
|
|
142
|
+
"primary and secondary are empty by construction — so no field_path test can see it — and whose keywords are " +
|
|
143
|
+
"substrings of the free text the entry grades.\n\n" +
|
|
144
|
+
"The identity is a LOCATOR, not an answer. Finding it in the payload is not finding it in the documents. " +
|
|
145
|
+
"Echoing it back is worthless: it will be counted as an agreement while proving nothing. " +
|
|
146
|
+
"Return `not_found` instead — it is a first-class, valuable answer.\n\n" +
|
|
147
|
+
"Lease in batches, work them, then lease again. When `entries` comes back EMPTY the pass is over: stop.", {
|
|
148
|
+
limit: optionalPositiveInt("How many entries to lease (server validates 1..25, default 10)"),
|
|
149
|
+
message: messageParam,
|
|
150
|
+
}, async ({ limit, message }) => {
|
|
151
|
+
const result = await client.leaseUnaudited({ limit, message });
|
|
152
|
+
return jsonResult(result.body);
|
|
153
|
+
});
|
|
154
|
+
server.tool("store_result", "Record what YOU found for one leased entry. The value must be the one you derived from the documents.\n\n" +
|
|
155
|
+
"verdict: 'certain' (you read the value on a page), 'possible' (you found something but are not sure — " +
|
|
156
|
+
"supply `confidence` 1..99), or 'not_found' (you could not find the record or the value). " +
|
|
157
|
+
"`value` is required for certain and possible, and must be OMITTED for not_found. " +
|
|
158
|
+
"`confidence` is required for possible and must be omitted for the other two.\n\n" +
|
|
159
|
+
"CITE EVERY certain AND EVERY possible with at least one {file_id, page} naming a page you actually read the " +
|
|
160
|
+
"value on. Nothing server-side forces this — `citations` is nullable — so it is an INSTRUCTION, and it is the " +
|
|
161
|
+
"only thing in the record that distinguishes a derived answer from a copied one. An uncited `certain` on an " +
|
|
162
|
+
"entry whose identity you were handed is indistinguishable from an echo and will be read as one.\n\n" +
|
|
163
|
+
"`reason` is always required. Never put page text or quoted source text in `reason` or `message` — patient records.\n\n" +
|
|
164
|
+
"THE SERVER DECIDES THE VERDICT. The result is " +
|
|
165
|
+
"{entry_id, audit_verdict, matched, identity_disclosed, remaining_count}. `audit_verdict` is one of " +
|
|
166
|
+
"confirmed | disputed | proposed | not_found — derived server-side from your value and the recorded answer, " +
|
|
167
|
+
"never from anything you send. `identity_disclosed` is a QUALIFIER on that verdict, never a fifth verdict: it " +
|
|
168
|
+
"is derived server-side by AnswerKeyEntry::isIdentityDisclosed() under all three disclosure shapes above, and " +
|
|
169
|
+
"is ignored if you send it. It marks an entry where a `confirmed` proves that the machine returned a value " +
|
|
170
|
+
"consistent with a name the payload handed it, and does NOT prove the machine independently found the record " +
|
|
171
|
+
"in the documents — while a `disputed` or `not_found` on the same entry IS real evidence, because nothing in " +
|
|
172
|
+
"the payload would produce that answer.\n\n" +
|
|
173
|
+
"Stop when a lease comes back with no entries. A 409 {reason: 'lease_lost'} means your claim expired or was " +
|
|
174
|
+
"retired: lease again rather than retrying this write.", {
|
|
175
|
+
entry_id: idParam("entry_id from the lease result"),
|
|
176
|
+
verdict: z
|
|
177
|
+
.enum(["certain", "possible", "not_found"])
|
|
178
|
+
.describe("certain: you read it on a page. possible: you found something but are unsure. not_found: you could not find it."),
|
|
179
|
+
value: z
|
|
180
|
+
.unknown()
|
|
181
|
+
.optional()
|
|
182
|
+
.describe("The value YOU derived from the documents. Required for certain and possible, omitted for not_found. " +
|
|
183
|
+
"For count/each_item modes it is a list; for every other mode it is a scalar."),
|
|
184
|
+
confidence: z.coerce
|
|
185
|
+
.number()
|
|
186
|
+
.int()
|
|
187
|
+
.min(1)
|
|
188
|
+
.max(99)
|
|
189
|
+
.optional()
|
|
190
|
+
.describe("Required for verdict=possible, omitted otherwise"),
|
|
191
|
+
citations: z
|
|
192
|
+
.array(z.object({
|
|
193
|
+
file_id: z
|
|
194
|
+
.string()
|
|
195
|
+
.describe("Document UUID string from the manifest"),
|
|
196
|
+
page: z.coerce
|
|
197
|
+
.number()
|
|
198
|
+
.int()
|
|
199
|
+
.positive()
|
|
200
|
+
.describe("1-based page number you read the value on"),
|
|
201
|
+
}))
|
|
202
|
+
.optional()
|
|
203
|
+
.describe("Pages you actually read the value on. Supply at least one for every certain and every possible."),
|
|
204
|
+
reason: z
|
|
205
|
+
.string()
|
|
206
|
+
.describe("Why you reached this verdict. Always required. Never quote page text here."),
|
|
207
|
+
message: messageParam,
|
|
208
|
+
}, async (args) => {
|
|
209
|
+
// Only keys the agent actually supplied are forwarded: the endpoint
|
|
210
|
+
// uses `missing_if` rules, so sending `confidence: null` on a
|
|
211
|
+
// `certain` verdict is a 422 rather than an omission.
|
|
212
|
+
const payload = {};
|
|
213
|
+
for (const [key, value] of Object.entries(args)) {
|
|
214
|
+
if (value !== undefined) {
|
|
215
|
+
payload[key] = value;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const result = await client.storeResult(payload);
|
|
219
|
+
return jsonResult(result.body);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const LOADED_VERSION: string;
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package's published version, read from package.json at module load and
|
|
3
|
+
* reported as `serverInfo.version` in the MCP initialize handshake.
|
|
4
|
+
*
|
|
5
|
+
* Dist layout assumption: `../package.json` resolves from BOTH
|
|
6
|
+
* `src/version.ts` and `dist/version.js` because both live one level below the
|
|
7
|
+
* package root. If tsconfig ever emits to a nested output dir, re-pin it here.
|
|
8
|
+
*
|
|
9
|
+
* There is deliberately NO disk-vs-memory drift assertion. The schema server
|
|
10
|
+
* carried one and retired it (SG-171): a concurrent publish clears
|
|
11
|
+
* `~/.npm/_npx/*` and the drift check then crashed in-flight dispatches with
|
|
12
|
+
* `MCP error -32000: Connection closed`. A running server serves its own loaded
|
|
13
|
+
* code; future spawns naturally fetch the newer version.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
18
|
+
const loadedPkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
19
|
+
export const LOADED_VERSION = loadedPkg.version;
|
package/package.json
CHANGED