@adrkit/mcp 0.2.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.
@@ -0,0 +1,299 @@
1
+ /**
2
+ * @adrkit/mcp — the per-call, in-memory corpus projection (data-model.md §3, §8).
3
+ *
4
+ * Rebuilt from scratch on every tool call: fresh canonical-root validation, the
5
+ * pre-read 64 KiB size guard, `discoverAdrFiles` + `lintCorpus`, a local multi-valued
6
+ * `byId` index, immutable corpus findings, and a canonical SHA-256 fingerprint.
7
+ * No cache, index, or database of any kind.
8
+ */
9
+
10
+ import { access, constants as FS, lstat, realpath, stat } from 'node:fs/promises';
11
+ import { createHash } from 'node:crypto';
12
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
13
+ import { discoverAdrFiles, lintCorpus, normalizeDisplayPath, type Adr, type Finding } from '@adrkit/core';
14
+ import { compareCodeUnits, sortFindingsCanonical } from './ordering.ts';
15
+
16
+ export const MAX_SOURCE_BYTES = 64 * 1024;
17
+
18
+ export type CorpusUnavailableReason =
19
+ | 'root-not-found'
20
+ | 'root-not-directory'
21
+ | 'root-not-readable'
22
+ | 'root-not-git'
23
+ | 'dir-not-found'
24
+ | 'dir-not-directory'
25
+ | 'dir-not-readable'
26
+ | 'dir-outside-root'
27
+ | 'corpus-changed-during-load';
28
+
29
+ /** Typed rejection carrying one of the `CorpusUnavailableOutcome` reasons (data-model.md §5). */
30
+ export class CorpusUnavailableError extends Error {
31
+ readonly reason: CorpusUnavailableReason;
32
+ constructor(reason: CorpusUnavailableReason) {
33
+ super(reason);
34
+ this.name = 'CorpusUnavailableError';
35
+ this.reason = reason;
36
+ }
37
+ }
38
+
39
+ export interface CorpusHealth {
40
+ readonly fingerprint: string;
41
+ readonly recordCount: number;
42
+ readonly excludedCount: number;
43
+ }
44
+
45
+ export interface CorpusProjection {
46
+ readonly records: readonly Adr[];
47
+ readonly byId: ReadonlyMap<string, readonly Adr[]>;
48
+ readonly corpusFindings: readonly Finding[];
49
+ readonly fingerprint: string;
50
+ readonly recordCount: number;
51
+ readonly excludedCount: number;
52
+ }
53
+
54
+ function fail(reason: CorpusUnavailableReason): never {
55
+ throw new CorpusUnavailableError(reason);
56
+ }
57
+
58
+ function errorCode(error: unknown): string | undefined {
59
+ return error && typeof error === 'object' && 'code' in error ? String((error as { code: unknown }).code) : undefined;
60
+ }
61
+
62
+ /** Path-segment-safe containment on two ALREADY-canonical paths (never a string prefix). */
63
+ function isContained(parent: string, child: string): boolean {
64
+ if (parent === child) return true;
65
+ const rel = relative(parent, child);
66
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
67
+ }
68
+
69
+ export interface ResolveCanonicalRootsOptions {
70
+ readonly cwd: string;
71
+ readonly dir: string;
72
+ }
73
+
74
+ export interface CanonicalRoots {
75
+ readonly canonicalCwd: string;
76
+ readonly canonicalDir: string;
77
+ }
78
+
79
+ /** The one canonicalization/containment routine both `start()` and every load call use. */
80
+ export async function resolveCanonicalRoots(options: ResolveCanonicalRootsOptions): Promise<CanonicalRoots> {
81
+ let canonicalCwd: string;
82
+ try {
83
+ canonicalCwd = await realpath(options.cwd);
84
+ } catch (error) {
85
+ fail(errorCode(error) === 'ENOENT' ? 'root-not-found' : 'root-not-readable');
86
+ }
87
+
88
+ try {
89
+ const info = await stat(canonicalCwd);
90
+ if (!info.isDirectory()) fail('root-not-directory');
91
+ } catch (error) {
92
+ if (error instanceof CorpusUnavailableError) throw error;
93
+ fail(errorCode(error) === 'ENOENT' ? 'root-not-found' : 'root-not-readable');
94
+ }
95
+ try {
96
+ await access(canonicalCwd, FS.R_OK | FS.X_OK);
97
+ } catch {
98
+ fail('root-not-readable');
99
+ }
100
+
101
+ const gitEntry = resolve(canonicalCwd, '.git');
102
+ try {
103
+ await stat(gitEntry);
104
+ await access(gitEntry, FS.R_OK);
105
+ } catch {
106
+ fail('root-not-git');
107
+ }
108
+
109
+ const dirInput = isAbsolute(options.dir) ? options.dir : resolve(canonicalCwd, options.dir);
110
+ let canonicalDir: string;
111
+ try {
112
+ canonicalDir = await realpath(dirInput);
113
+ } catch (error) {
114
+ fail(errorCode(error) === 'ENOENT' ? 'dir-not-found' : 'dir-not-readable');
115
+ }
116
+
117
+ try {
118
+ const info = await stat(canonicalDir);
119
+ if (!info.isDirectory()) fail('dir-not-directory');
120
+ } catch (error) {
121
+ if (error instanceof CorpusUnavailableError) throw error;
122
+ fail(errorCode(error) === 'ENOENT' ? 'dir-not-found' : 'dir-not-readable');
123
+ }
124
+ try {
125
+ await access(canonicalDir, FS.R_OK | FS.X_OK);
126
+ } catch {
127
+ fail('dir-not-readable');
128
+ }
129
+
130
+ if (!isContained(canonicalCwd, canonicalDir)) fail('dir-outside-root');
131
+
132
+ return { canonicalCwd, canonicalDir };
133
+ }
134
+
135
+ export interface LoadCorpusProjectionOptions {
136
+ readonly configuredCwd: string;
137
+ readonly configuredDir: string;
138
+ readonly expectedCanonicalCwd: string;
139
+ readonly maxSourceBytes: number;
140
+ }
141
+
142
+ interface KeptCandidate {
143
+ readonly absolutePath: string;
144
+ readonly displayPath: string;
145
+ readonly identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint };
146
+ }
147
+
148
+ function statError(displayPath: string): Finding {
149
+ return {
150
+ rule: 'record-stat-error',
151
+ severity: 'error',
152
+ path: displayPath,
153
+ message: 'ADR candidate could not be read to validate its size and was excluded from this response',
154
+ };
155
+ }
156
+
157
+ function tooLarge(displayPath: string): Finding {
158
+ return {
159
+ rule: 'record-too-large',
160
+ severity: 'error',
161
+ path: displayPath,
162
+ message: 'ADR source exceeds the 64 KiB maximum and was excluded from this response',
163
+ };
164
+ }
165
+
166
+ async function verifyRoots(options: LoadCorpusProjectionOptions): Promise<CanonicalRoots> {
167
+ const roots = await resolveCanonicalRoots({ cwd: options.configuredCwd, dir: options.configuredDir });
168
+ if (roots.canonicalCwd !== options.expectedCanonicalCwd) fail('root-not-found');
169
+ return roots;
170
+ }
171
+
172
+ function canonicalStringify(value: unknown): string {
173
+ if (value === null || value === undefined) return 'null';
174
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') {
175
+ return JSON.stringify(value);
176
+ }
177
+ if (Array.isArray(value)) return `[${value.map(canonicalStringify).join(',')}]`;
178
+ if (typeof value === 'object') {
179
+ const record = value as Record<string, unknown>;
180
+ const keys = Object.keys(record)
181
+ .filter((key) => record[key] !== undefined)
182
+ .sort(compareCodeUnits);
183
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalStringify(record[key])}`).join(',')}}`;
184
+ }
185
+ return 'null';
186
+ }
187
+
188
+ function fingerprintOf(records: readonly Adr[], corpusFindings: readonly Finding[], recordCount: number, excludedCount: number): string {
189
+ const projection = {
190
+ records: records.map((record) => ({ sourcePath: record.path, frontmatter: record.frontmatter, body: record.body })),
191
+ corpusFindings,
192
+ corpusHealth: { recordCount, excludedCount },
193
+ };
194
+ return createHash('sha256').update(canonicalStringify(projection), 'utf8').digest('hex');
195
+ }
196
+
197
+ /** The one entry point every tool handler calls, fresh, at the start of its execution. */
198
+ export async function loadCorpusProjection(options: LoadCorpusProjectionOptions): Promise<CorpusProjection> {
199
+ const roots = await verifyRoots(options);
200
+
201
+ let candidates: string[];
202
+ try {
203
+ candidates = await discoverAdrFiles(roots.canonicalDir, roots.canonicalCwd);
204
+ } catch (error) {
205
+ fail(errorCode(error) === 'ENOENT' ? 'dir-not-found' : 'dir-not-readable');
206
+ }
207
+
208
+ const preReadFindings: Finding[] = [];
209
+ const kept: KeptCandidate[] = [];
210
+
211
+ for (const candidate of candidates) {
212
+ const displayPath = normalizeDisplayPath(candidate, roots.canonicalCwd);
213
+ try {
214
+ const link = await lstat(candidate);
215
+ if (link.isSymbolicLink() || !link.isFile()) {
216
+ preReadFindings.push(statError(displayPath));
217
+ continue;
218
+ }
219
+ const real = await realpath(candidate);
220
+ if (!isContained(roots.canonicalDir, real)) {
221
+ preReadFindings.push(statError(displayPath));
222
+ continue;
223
+ }
224
+ const info = await stat(candidate, { bigint: true });
225
+ if (Number(info.size) > options.maxSourceBytes) {
226
+ preReadFindings.push(tooLarge(displayPath));
227
+ continue;
228
+ }
229
+ kept.push({
230
+ absolutePath: candidate,
231
+ displayPath,
232
+ identity: { dev: info.dev, ino: info.ino, size: info.size, mtimeNs: info.mtimeNs },
233
+ });
234
+ } catch {
235
+ preReadFindings.push(statError(displayPath));
236
+ }
237
+ }
238
+
239
+ // Never call lintCorpus with an empty paths array: expandRecordInputs would
240
+ // re-discover the whole directory and reinstate the excluded files (data-model.md §3.3).
241
+ let records: Adr[] = [];
242
+ let lintFindings: Finding[] = [];
243
+ if (kept.length > 0) {
244
+ const result = await lintCorpus({ paths: kept.map((k) => k.absolutePath), cwd: roots.canonicalCwd });
245
+ records = result.records;
246
+ lintFindings = result.findings;
247
+ }
248
+
249
+ // Post-load revalidation: roots plus every kept candidate's type, containment, and identity.
250
+ const postRoots = await verifyRoots(options);
251
+ if (postRoots.canonicalDir !== roots.canonicalDir) fail('corpus-changed-during-load');
252
+ for (const candidate of kept) {
253
+ try {
254
+ const link = await lstat(candidate.absolutePath);
255
+ if (link.isSymbolicLink() || !link.isFile()) fail('corpus-changed-during-load');
256
+ const real = await realpath(candidate.absolutePath);
257
+ if (!isContained(postRoots.canonicalDir, real)) fail('corpus-changed-during-load');
258
+ const info = await stat(candidate.absolutePath, { bigint: true });
259
+ if (Number(info.size) > options.maxSourceBytes) fail('corpus-changed-during-load');
260
+ if (
261
+ info.dev !== candidate.identity.dev ||
262
+ info.ino !== candidate.identity.ino ||
263
+ info.size !== candidate.identity.size ||
264
+ info.mtimeNs !== candidate.identity.mtimeNs
265
+ ) {
266
+ fail('corpus-changed-during-load');
267
+ }
268
+ } catch (error) {
269
+ if (error instanceof CorpusUnavailableError) throw error;
270
+ fail('corpus-changed-during-load');
271
+ }
272
+ }
273
+
274
+ const orderedRecords = [...records].sort(
275
+ (a, b) => compareCodeUnits(a.frontmatter.id, b.frontmatter.id) || compareCodeUnits(a.path, b.path),
276
+ );
277
+
278
+ const byId = new Map<string, Adr[]>();
279
+ for (const record of orderedRecords) {
280
+ const bucket = byId.get(record.frontmatter.id) ?? [];
281
+ bucket.push(record);
282
+ byId.set(record.frontmatter.id, bucket);
283
+ }
284
+ // Each bucket inherits `orderedRecords`' canonical (id, sourcePath) order already.
285
+
286
+ const corpusFindings = sortFindingsCanonical([...lintFindings, ...preReadFindings]);
287
+ const recordCount = orderedRecords.length;
288
+ const excludedCount = candidates.length - recordCount;
289
+ const fingerprint = fingerprintOf(orderedRecords, corpusFindings, recordCount, excludedCount);
290
+
291
+ return {
292
+ records: orderedRecords,
293
+ byId,
294
+ corpusFindings,
295
+ fingerprint,
296
+ recordCount,
297
+ excludedCount,
298
+ };
299
+ }
package/src/index.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @adrkit/mcp — public entry point.
3
+ *
4
+ * Exports ONLY the sealed stdio lifecycle factory and its option/handle types.
5
+ * No SDK server, registration API, internal builder, transport, or test subpath is
6
+ * reachable from here. No side effects at import time; no filesystem access at
7
+ * construction time (data-model.md §8, contracts/tools.md §1).
8
+ */
9
+
10
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
+ import { resolve } from 'node:path';
13
+ import { buildRegisteredServer } from './server.ts';
14
+ import { resolveCanonicalRoots, MAX_SOURCE_BYTES } from './corpus/projection.ts';
15
+ import type { ToolConfig } from './tools/shared.ts';
16
+
17
+ export interface AdrkitMcpServerOptions {
18
+ readonly cwd: string;
19
+ readonly dir: string;
20
+ }
21
+
22
+ export interface AdrkitMcpServerHandle {
23
+ start(): Promise<void>;
24
+ close(): Promise<void>;
25
+ }
26
+
27
+ /**
28
+ * The public stdio lifecycle factory. Performs NO filesystem access at construction;
29
+ * `start()` validates the configured root, builds the closure-private server, creates
30
+ * exactly one `StdioServerTransport`, and connects it. The concrete server, its
31
+ * registrations, and its transport remain unreachable to the caller.
32
+ */
33
+ export function createAdrkitMcpServer(
34
+ options?: Partial<AdrkitMcpServerOptions>,
35
+ ): Readonly<AdrkitMcpServerHandle> {
36
+ const cwd = resolve(options?.cwd ?? process.cwd());
37
+ const dir = options?.dir ?? 'docs/adr';
38
+
39
+ let server: McpServer | undefined;
40
+ let startPromise: Promise<void> | undefined;
41
+ let closePromise: Promise<void> | undefined;
42
+ let closed = false;
43
+
44
+ function start(): Promise<void> {
45
+ if (closed) {
46
+ return Promise.reject(new Error('adrkit MCP server handle is closed'));
47
+ }
48
+ if (startPromise) return startPromise;
49
+
50
+ startPromise = (async () => {
51
+ let nextServer: McpServer | undefined;
52
+ try {
53
+ const roots = await resolveCanonicalRoots({ cwd, dir });
54
+ const config: ToolConfig = {
55
+ configuredCwd: cwd,
56
+ configuredDir: dir,
57
+ expectedCanonicalCwd: roots.canonicalCwd,
58
+ maxSourceBytes: MAX_SOURCE_BYTES,
59
+ };
60
+ nextServer = buildRegisteredServer(config);
61
+ server = nextServer;
62
+ await nextServer.connect(new StdioServerTransport());
63
+ } catch (error) {
64
+ server = undefined;
65
+ if (nextServer) {
66
+ try {
67
+ await nextServer.close();
68
+ } catch (closeError) {
69
+ startPromise = undefined;
70
+ throw new AggregateError([error, closeError], 'MCP server startup and cleanup failed');
71
+ }
72
+ }
73
+ startPromise = undefined;
74
+ throw error;
75
+ }
76
+ })();
77
+ return startPromise;
78
+ }
79
+
80
+ function close(): Promise<void> {
81
+ if (closePromise) return closePromise;
82
+ closed = true;
83
+ closePromise = (async () => {
84
+ if (startPromise) await startPromise;
85
+ const current = server;
86
+ server = undefined;
87
+ if (current) await current.close();
88
+ })();
89
+ return closePromise;
90
+ }
91
+
92
+ const handle = Object.create(null) as AdrkitMcpServerHandle;
93
+ handle.start = start;
94
+ handle.close = close;
95
+ return Object.freeze(handle);
96
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @adrkit/mcp — CLI/startup helpers for the `adrkit-mcp` bin.
3
+ *
4
+ * The only place `process.argv`/`process.env` are read, the FR-036 startup check
5
+ * lives, and the `isMainModule()` guard is defined. A local helper mirroring
6
+ * `packages/cli/src/main-module.ts` (not imported from it). stdout is reserved for
7
+ * protocol frames; every diagnostic goes to stderr.
8
+ */
9
+
10
+ import { existsSync, realpathSync } from 'node:fs';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { parseArgs } from 'node:util';
13
+ import { createAdrkitMcpServer } from './index.ts';
14
+ import { resolveCanonicalRoots, CorpusUnavailableError } from './corpus/projection.ts';
15
+ import { CORPUS_UNAVAILABLE_MESSAGES } from './tools/shared.ts';
16
+
17
+ export function isMainModule(moduleUrl: string, argvPath: string | undefined): boolean {
18
+ if (!argvPath || !existsSync(argvPath)) return false;
19
+ return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(argvPath);
20
+ }
21
+
22
+ const USAGE = 'usage: adrkit-mcp [--cwd <path>] [--dir <path>]\n';
23
+
24
+ function writeStderr(text: string): void {
25
+ process.stderr.write(text);
26
+ }
27
+
28
+ export function reportUnhandledRejection(
29
+ reason: unknown,
30
+ write: (text: string) => void = writeStderr,
31
+ fail: () => void = () => {
32
+ process.exitCode = 1;
33
+ },
34
+ ): void {
35
+ const detail = reason instanceof Error ? reason.message : String(reason);
36
+ write(`adrkit-mcp: unhandled rejection: ${detail}\n`);
37
+ fail();
38
+ }
39
+
40
+ export async function main(
41
+ argv: string[],
42
+ env: Record<string, string | undefined>,
43
+ ): Promise<0 | 1 | 2> {
44
+ let values: { cwd?: string; dir?: string };
45
+ try {
46
+ ({ values } = parseArgs({
47
+ args: argv,
48
+ options: { cwd: { type: 'string' }, dir: { type: 'string' } },
49
+ strict: true,
50
+ allowPositionals: false,
51
+ }));
52
+ } catch (error) {
53
+ writeStderr(`${error instanceof Error ? error.message : String(error)}\n${USAGE}`);
54
+ return 2;
55
+ }
56
+
57
+ const cwd = values.cwd ?? env.ADRKIT_MCP_CWD ?? process.cwd();
58
+ const dir = values.dir ?? env.ADRKIT_MCP_DIR ?? 'docs/adr';
59
+
60
+ // Fail-fast startup check (FR-036): validate the configured root before any transport.
61
+ try {
62
+ await resolveCanonicalRoots({ cwd, dir });
63
+ } catch (error) {
64
+ if (error instanceof CorpusUnavailableError) {
65
+ writeStderr(`${CORPUS_UNAVAILABLE_MESSAGES[error.reason]}\n`);
66
+ return 1;
67
+ }
68
+ throw error;
69
+ }
70
+
71
+ const handle = createAdrkitMcpServer({ cwd, dir });
72
+
73
+ const shutdown = (): void => {
74
+ void handle.close().finally(() => process.exit(0));
75
+ };
76
+ process.once('SIGINT', shutdown);
77
+ process.once('SIGTERM', shutdown);
78
+ process.on('unhandledRejection', (reason) => reportUnhandledRejection(reason));
79
+
80
+ await handle.start();
81
+ return 0;
82
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @adrkit/mcp — opaque, versioned, query-bound pagination cursors and the
3
+ * query-shape hash (contracts/pagination-and-cursors.md).
4
+ */
5
+
6
+ import { createHash } from 'node:crypto';
7
+
8
+ export type CursorScope =
9
+ | 'search.results'
10
+ | 'search.findings'
11
+ | 'get_decision.candidates'
12
+ | 'get_decision.findings'
13
+ | 'context.results'
14
+ | 'context.findings'
15
+ | 'superseded.results'
16
+ | 'superseded.findings';
17
+
18
+ export interface CursorPayloadV1 {
19
+ readonly v: 1;
20
+ readonly scope: CursorScope;
21
+ readonly fp: string;
22
+ readonly qh: string;
23
+ readonly offset: number;
24
+ }
25
+
26
+ export type InvalidCursorReason =
27
+ | 'decode-failed'
28
+ | 'version-unsupported'
29
+ | 'wrong-channel'
30
+ | 'corpus-changed'
31
+ | 'query-mismatch'
32
+ | 'cursor-not-applicable'
33
+ | 'offset-out-of-range';
34
+
35
+ export interface Page<T> {
36
+ readonly items: readonly T[];
37
+ readonly cursor: string | null;
38
+ }
39
+
40
+ const CURSOR_KEYS = ['v', 'scope', 'fp', 'qh', 'offset'] as const;
41
+
42
+ /** Fixed-field-order base64url(no-pad) UTF-8 JSON — byte-identical for identical payloads. */
43
+ export function encodeCursor(payload: CursorPayloadV1): string {
44
+ const json =
45
+ `{"v":1,"scope":${JSON.stringify(payload.scope)},` +
46
+ `"fp":${JSON.stringify(payload.fp)},"qh":${JSON.stringify(payload.qh)},` +
47
+ `"offset":${payload.offset}}`;
48
+ return Buffer.from(json, 'utf8').toString('base64url');
49
+ }
50
+
51
+ /** SHA-256 hex over a fixed-order canonical JSON array of the hashed parameters. */
52
+ export function queryShapeHash(parts: readonly unknown[]): string {
53
+ return createHash('sha256').update(JSON.stringify(parts)).digest('hex');
54
+ }
55
+
56
+ export interface VerifyCursorOptions {
57
+ readonly cursor: string;
58
+ readonly expectedScope: CursorScope;
59
+ readonly fp: string;
60
+ readonly qh: string;
61
+ }
62
+
63
+ export type VerifyCursorResult =
64
+ | { readonly ok: true; readonly offset: number }
65
+ | { readonly ok: false; readonly reason: InvalidCursorReason };
66
+
67
+ function decodeStrict(
68
+ cursor: string,
69
+ ): { v: number; scope: string; fp: string; qh: string; offset: number } | undefined {
70
+ let text: string;
71
+ try {
72
+ text = Buffer.from(cursor, 'base64url').toString('utf8');
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ let parsed: unknown;
77
+ try {
78
+ parsed = JSON.parse(text);
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
83
+ const keys = Object.keys(parsed);
84
+ if (keys.length !== CURSOR_KEYS.length || !CURSOR_KEYS.every((k) => keys.includes(k))) return undefined;
85
+ const obj = parsed as Record<string, unknown>;
86
+ if (
87
+ typeof obj.v !== 'number' ||
88
+ typeof obj.scope !== 'string' ||
89
+ typeof obj.fp !== 'string' ||
90
+ typeof obj.qh !== 'string' ||
91
+ typeof obj.offset !== 'number' ||
92
+ !Number.isSafeInteger(obj.offset) ||
93
+ obj.offset < 1
94
+ ) {
95
+ return undefined;
96
+ }
97
+ return { v: obj.v, scope: obj.scope, fp: obj.fp, qh: obj.qh, offset: obj.offset };
98
+ }
99
+
100
+ /** Decode/verify steps 1-6 (pagination §2). */
101
+ export function verifyCursor(options: VerifyCursorOptions): VerifyCursorResult {
102
+ const decoded = decodeStrict(options.cursor);
103
+ if (!decoded) return { ok: false, reason: 'decode-failed' };
104
+ if (decoded.v !== 1) return { ok: false, reason: 'version-unsupported' };
105
+ if (decoded.scope !== options.expectedScope) return { ok: false, reason: 'wrong-channel' };
106
+ if (decoded.fp !== options.fp) return { ok: false, reason: 'corpus-changed' };
107
+ if (decoded.qh !== options.qh) return { ok: false, reason: 'query-mismatch' };
108
+ return { ok: true, offset: decoded.offset };
109
+ }
110
+
111
+ export interface PaginateOptions<T> {
112
+ readonly items: readonly T[];
113
+ readonly cursor: string | undefined;
114
+ readonly limit: number;
115
+ readonly scope: CursorScope;
116
+ readonly fp: string;
117
+ readonly qh: string;
118
+ }
119
+
120
+ export type PaginateResult<T> =
121
+ | { readonly ok: true; readonly page: Page<T> }
122
+ | { readonly ok: false; readonly reason: InvalidCursorReason };
123
+
124
+ /** Full applicable-channel pagination: verify (1-6), range-check (8), slice (9), mint. */
125
+ export function paginate<T>(options: PaginateOptions<T>): PaginateResult<T> {
126
+ const { items, cursor, limit, scope, fp, qh } = options;
127
+ let offset = 0;
128
+ if (cursor !== undefined) {
129
+ const verified = verifyCursor({ cursor, expectedScope: scope, fp, qh });
130
+ if (!verified.ok) return { ok: false, reason: verified.reason };
131
+ offset = verified.offset;
132
+ if (offset >= items.length) return { ok: false, reason: 'offset-out-of-range' };
133
+ }
134
+ const slice = items.slice(offset, offset + limit);
135
+ const nextOffset = offset + slice.length;
136
+ const nextCursor = nextOffset < items.length ? encodeCursor({ v: 1, scope, fp, qh, offset: nextOffset }) : null;
137
+ return { ok: true, page: { items: slice, cursor: nextCursor } };
138
+ }
139
+
140
+ export interface InapplicablePrimaryOptions {
141
+ readonly cursor: string | undefined;
142
+ readonly scope: CursorScope;
143
+ readonly fp: string;
144
+ readonly qh: string;
145
+ }
146
+
147
+ export type InapplicablePrimaryResult =
148
+ | { readonly ok: true }
149
+ | { readonly ok: false; readonly reason: InvalidCursorReason };
150
+
151
+ /** A primary cursor supplied against an outcome with no primary channel (step 7). */
152
+ export function checkInapplicablePrimaryCursor(options: InapplicablePrimaryOptions): InapplicablePrimaryResult {
153
+ if (options.cursor === undefined) return { ok: true };
154
+ const verified = verifyCursor({
155
+ cursor: options.cursor,
156
+ expectedScope: options.scope,
157
+ fp: options.fp,
158
+ qh: options.qh,
159
+ });
160
+ if (!verified.ok) return { ok: false, reason: verified.reason };
161
+ return { ok: false, reason: 'cursor-not-applicable' };
162
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @adrkit/mcp — deterministic search normalization (research §R5).
3
+ *
4
+ * `normalize(s) = s.trim().normalize('NFKC').toLowerCase()` — engine-builtin,
5
+ * non-ICU, locale-independent. No stemming, fuzzy, weighting, or ranking.
6
+ */
7
+ export function normalize(value: string): string {
8
+ return value.trim().normalize('NFKC').toLowerCase();
9
+ }
package/src/server.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @adrkit/mcp — internal server assembly (package-internal, never publicly exported).
3
+ *
4
+ * Owns the concrete `McpServer`, its registration APIs, and the in-memory builder
5
+ * used only by in-process conformance tests. The public factory (`./index.ts`)
6
+ * exposes only the sealed lifecycle handle. This module is absent from
7
+ * `package.json#exports` and every public subpath.
8
+ */
9
+
10
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ import { registerSearchDecisions } from './tools/search-decisions.ts';
12
+ import { registerGetDecision } from './tools/get-decision.ts';
13
+ import { registerGetDecisionContext } from './tools/get-decision-context.ts';
14
+ import { registerListSuperseded } from './tools/list-superseded.ts';
15
+ import type { ToolConfig } from './tools/shared.ts';
16
+
17
+ export const SERVER_INFO = { name: '@adrkit/mcp', version: '0.1.0' } as const;
18
+
19
+ /** Package-internal: build the concrete server with exactly the four ratified tools. */
20
+ export function buildRegisteredServer(config: ToolConfig): McpServer {
21
+ const server = new McpServer(SERVER_INFO);
22
+ registerSearchDecisions(server, config);
23
+ registerGetDecision(server, config);
24
+ registerGetDecisionContext(server, config);
25
+ registerListSuperseded(server, config);
26
+ return server;
27
+ }