@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.
@@ -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 };
@@ -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;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/answer-key-mcp-server",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "MCP server for the blind answer-key audit agent - three proxy tools over gpt-manager's answer-key MCP endpoints",
5
5
  "license": "MIT",
6
6
  "repository": {