@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.
- package/README.md +99 -0
- package/dist/LICENSE +201 -0
- package/dist/NOTICE +11 -0
- package/dist/bin.d.ts +8 -0
- package/dist/bin.js +1156 -0
- package/dist/corpus/ordering.d.ts +17 -0
- package/dist/corpus/projection.d.ts +47 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +1093 -0
- package/dist/main-module.d.ts +11 -0
- package/dist/pagination/cursor.d.ts +67 -0
- package/dist/search/normalize.d.ts +7 -0
- package/dist/server.d.ts +16 -0
- package/dist/tools/get-decision-context.d.ts +11 -0
- package/dist/tools/get-decision.d.ts +11 -0
- package/dist/tools/list-superseded.d.ts +11 -0
- package/dist/tools/search-decisions.d.ts +11 -0
- package/dist/tools/shared.d.ts +252 -0
- package/package.json +59 -0
- package/src/bin.ts +13 -0
- package/src/corpus/ordering.ts +41 -0
- package/src/corpus/projection.ts +299 -0
- package/src/index.ts +96 -0
- package/src/main-module.ts +82 -0
- package/src/pagination/cursor.ts +162 -0
- package/src/search/normalize.ts +9 -0
- package/src/server.ts +27 -0
- package/src/tools/get-decision-context.ts +137 -0
- package/src/tools/get-decision.ts +169 -0
- package/src/tools/list-superseded.ts +148 -0
- package/src/tools/search-decisions.ts +131 -0
- package/src/tools/shared.ts +583 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { resolve as resolve2 } from "node:path";
|
|
4
|
+
|
|
5
|
+
// src/server.ts
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
|
|
8
|
+
// src/corpus/ordering.ts
|
|
9
|
+
function compareCodeUnits(a, b) {
|
|
10
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
11
|
+
}
|
|
12
|
+
function compareFindings(a, b) {
|
|
13
|
+
return compareCodeUnits(a.rule, b.rule) || compareCodeUnits(a.id ?? "", b.id ?? "") || compareCodeUnits(a.pattern ?? "", b.pattern ?? "") || compareCodeUnits(a.path ?? "", b.path ?? "") || compareCodeUnits(a.field ?? "", b.field ?? "") || compareCodeUnits(a.message, b.message);
|
|
14
|
+
}
|
|
15
|
+
function sortFindingsCanonical(findings) {
|
|
16
|
+
return [...findings].sort(compareFindings);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// src/search/normalize.ts
|
|
20
|
+
function normalize(value) {
|
|
21
|
+
return value.trim().normalize("NFKC").toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/pagination/cursor.ts
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
var CURSOR_KEYS = ["v", "scope", "fp", "qh", "offset"];
|
|
27
|
+
function encodeCursor(payload) {
|
|
28
|
+
const json = `{"v":1,"scope":${JSON.stringify(payload.scope)},` + `"fp":${JSON.stringify(payload.fp)},"qh":${JSON.stringify(payload.qh)},` + `"offset":${payload.offset}}`;
|
|
29
|
+
return Buffer.from(json, "utf8").toString("base64url");
|
|
30
|
+
}
|
|
31
|
+
function queryShapeHash(parts) {
|
|
32
|
+
return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
33
|
+
}
|
|
34
|
+
function decodeStrict(cursor) {
|
|
35
|
+
let text;
|
|
36
|
+
try {
|
|
37
|
+
text = Buffer.from(cursor, "base64url").toString("utf8");
|
|
38
|
+
} catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(text);
|
|
44
|
+
} catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
48
|
+
return;
|
|
49
|
+
const keys = Object.keys(parsed);
|
|
50
|
+
if (keys.length !== CURSOR_KEYS.length || !CURSOR_KEYS.every((k) => keys.includes(k)))
|
|
51
|
+
return;
|
|
52
|
+
const obj = parsed;
|
|
53
|
+
if (typeof obj.v !== "number" || typeof obj.scope !== "string" || typeof obj.fp !== "string" || typeof obj.qh !== "string" || typeof obj.offset !== "number" || !Number.isSafeInteger(obj.offset) || obj.offset < 1) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
return { v: obj.v, scope: obj.scope, fp: obj.fp, qh: obj.qh, offset: obj.offset };
|
|
57
|
+
}
|
|
58
|
+
function verifyCursor(options) {
|
|
59
|
+
const decoded = decodeStrict(options.cursor);
|
|
60
|
+
if (!decoded)
|
|
61
|
+
return { ok: false, reason: "decode-failed" };
|
|
62
|
+
if (decoded.v !== 1)
|
|
63
|
+
return { ok: false, reason: "version-unsupported" };
|
|
64
|
+
if (decoded.scope !== options.expectedScope)
|
|
65
|
+
return { ok: false, reason: "wrong-channel" };
|
|
66
|
+
if (decoded.fp !== options.fp)
|
|
67
|
+
return { ok: false, reason: "corpus-changed" };
|
|
68
|
+
if (decoded.qh !== options.qh)
|
|
69
|
+
return { ok: false, reason: "query-mismatch" };
|
|
70
|
+
return { ok: true, offset: decoded.offset };
|
|
71
|
+
}
|
|
72
|
+
function paginate(options) {
|
|
73
|
+
const { items, cursor, limit, scope, fp, qh } = options;
|
|
74
|
+
let offset = 0;
|
|
75
|
+
if (cursor !== undefined) {
|
|
76
|
+
const verified = verifyCursor({ cursor, expectedScope: scope, fp, qh });
|
|
77
|
+
if (!verified.ok)
|
|
78
|
+
return { ok: false, reason: verified.reason };
|
|
79
|
+
offset = verified.offset;
|
|
80
|
+
if (offset >= items.length)
|
|
81
|
+
return { ok: false, reason: "offset-out-of-range" };
|
|
82
|
+
}
|
|
83
|
+
const slice = items.slice(offset, offset + limit);
|
|
84
|
+
const nextOffset = offset + slice.length;
|
|
85
|
+
const nextCursor = nextOffset < items.length ? encodeCursor({ v: 1, scope, fp, qh, offset: nextOffset }) : null;
|
|
86
|
+
return { ok: true, page: { items: slice, cursor: nextCursor } };
|
|
87
|
+
}
|
|
88
|
+
function checkInapplicablePrimaryCursor(options) {
|
|
89
|
+
if (options.cursor === undefined)
|
|
90
|
+
return { ok: true };
|
|
91
|
+
const verified = verifyCursor({
|
|
92
|
+
cursor: options.cursor,
|
|
93
|
+
expectedScope: options.scope,
|
|
94
|
+
fp: options.fp,
|
|
95
|
+
qh: options.qh
|
|
96
|
+
});
|
|
97
|
+
if (!verified.ok)
|
|
98
|
+
return { ok: false, reason: verified.reason };
|
|
99
|
+
return { ok: false, reason: "cursor-not-applicable" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/tools/shared.ts
|
|
103
|
+
import { z } from "zod";
|
|
104
|
+
import { AdrFrontmatter, AdrRef, Status, Scope } from "@adrkit/core";
|
|
105
|
+
|
|
106
|
+
// src/corpus/projection.ts
|
|
107
|
+
import { access, constants as FS, lstat, realpath, stat } from "node:fs/promises";
|
|
108
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
109
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
110
|
+
import { discoverAdrFiles, lintCorpus, normalizeDisplayPath } from "@adrkit/core";
|
|
111
|
+
var MAX_SOURCE_BYTES = 64 * 1024;
|
|
112
|
+
|
|
113
|
+
class CorpusUnavailableError extends Error {
|
|
114
|
+
reason;
|
|
115
|
+
constructor(reason) {
|
|
116
|
+
super(reason);
|
|
117
|
+
this.name = "CorpusUnavailableError";
|
|
118
|
+
this.reason = reason;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function fail(reason) {
|
|
122
|
+
throw new CorpusUnavailableError(reason);
|
|
123
|
+
}
|
|
124
|
+
function errorCode(error) {
|
|
125
|
+
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
|
|
126
|
+
}
|
|
127
|
+
function isContained(parent, child) {
|
|
128
|
+
if (parent === child)
|
|
129
|
+
return true;
|
|
130
|
+
const rel = relative(parent, child);
|
|
131
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
132
|
+
}
|
|
133
|
+
async function resolveCanonicalRoots(options) {
|
|
134
|
+
let canonicalCwd;
|
|
135
|
+
try {
|
|
136
|
+
canonicalCwd = await realpath(options.cwd);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
fail(errorCode(error) === "ENOENT" ? "root-not-found" : "root-not-readable");
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const info = await stat(canonicalCwd);
|
|
142
|
+
if (!info.isDirectory())
|
|
143
|
+
fail("root-not-directory");
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error instanceof CorpusUnavailableError)
|
|
146
|
+
throw error;
|
|
147
|
+
fail(errorCode(error) === "ENOENT" ? "root-not-found" : "root-not-readable");
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
await access(canonicalCwd, FS.R_OK | FS.X_OK);
|
|
151
|
+
} catch {
|
|
152
|
+
fail("root-not-readable");
|
|
153
|
+
}
|
|
154
|
+
const gitEntry = resolve(canonicalCwd, ".git");
|
|
155
|
+
try {
|
|
156
|
+
await stat(gitEntry);
|
|
157
|
+
await access(gitEntry, FS.R_OK);
|
|
158
|
+
} catch {
|
|
159
|
+
fail("root-not-git");
|
|
160
|
+
}
|
|
161
|
+
const dirInput = isAbsolute(options.dir) ? options.dir : resolve(canonicalCwd, options.dir);
|
|
162
|
+
let canonicalDir;
|
|
163
|
+
try {
|
|
164
|
+
canonicalDir = await realpath(dirInput);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
fail(errorCode(error) === "ENOENT" ? "dir-not-found" : "dir-not-readable");
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const info = await stat(canonicalDir);
|
|
170
|
+
if (!info.isDirectory())
|
|
171
|
+
fail("dir-not-directory");
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error instanceof CorpusUnavailableError)
|
|
174
|
+
throw error;
|
|
175
|
+
fail(errorCode(error) === "ENOENT" ? "dir-not-found" : "dir-not-readable");
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
await access(canonicalDir, FS.R_OK | FS.X_OK);
|
|
179
|
+
} catch {
|
|
180
|
+
fail("dir-not-readable");
|
|
181
|
+
}
|
|
182
|
+
if (!isContained(canonicalCwd, canonicalDir))
|
|
183
|
+
fail("dir-outside-root");
|
|
184
|
+
return { canonicalCwd, canonicalDir };
|
|
185
|
+
}
|
|
186
|
+
function statError(displayPath) {
|
|
187
|
+
return {
|
|
188
|
+
rule: "record-stat-error",
|
|
189
|
+
severity: "error",
|
|
190
|
+
path: displayPath,
|
|
191
|
+
message: "ADR candidate could not be read to validate its size and was excluded from this response"
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function tooLarge(displayPath) {
|
|
195
|
+
return {
|
|
196
|
+
rule: "record-too-large",
|
|
197
|
+
severity: "error",
|
|
198
|
+
path: displayPath,
|
|
199
|
+
message: "ADR source exceeds the 64 KiB maximum and was excluded from this response"
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
async function verifyRoots(options) {
|
|
203
|
+
const roots = await resolveCanonicalRoots({ cwd: options.configuredCwd, dir: options.configuredDir });
|
|
204
|
+
if (roots.canonicalCwd !== options.expectedCanonicalCwd)
|
|
205
|
+
fail("root-not-found");
|
|
206
|
+
return roots;
|
|
207
|
+
}
|
|
208
|
+
function canonicalStringify(value) {
|
|
209
|
+
if (value === null || value === undefined)
|
|
210
|
+
return "null";
|
|
211
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "string") {
|
|
212
|
+
return JSON.stringify(value);
|
|
213
|
+
}
|
|
214
|
+
if (Array.isArray(value))
|
|
215
|
+
return `[${value.map(canonicalStringify).join(",")}]`;
|
|
216
|
+
if (typeof value === "object") {
|
|
217
|
+
const record = value;
|
|
218
|
+
const keys = Object.keys(record).filter((key) => record[key] !== undefined).sort(compareCodeUnits);
|
|
219
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalStringify(record[key])}`).join(",")}}`;
|
|
220
|
+
}
|
|
221
|
+
return "null";
|
|
222
|
+
}
|
|
223
|
+
function fingerprintOf(records, corpusFindings, recordCount, excludedCount) {
|
|
224
|
+
const projection = {
|
|
225
|
+
records: records.map((record) => ({ sourcePath: record.path, frontmatter: record.frontmatter, body: record.body })),
|
|
226
|
+
corpusFindings,
|
|
227
|
+
corpusHealth: { recordCount, excludedCount }
|
|
228
|
+
};
|
|
229
|
+
return createHash2("sha256").update(canonicalStringify(projection), "utf8").digest("hex");
|
|
230
|
+
}
|
|
231
|
+
async function loadCorpusProjection(options) {
|
|
232
|
+
const roots = await verifyRoots(options);
|
|
233
|
+
let candidates;
|
|
234
|
+
try {
|
|
235
|
+
candidates = await discoverAdrFiles(roots.canonicalDir, roots.canonicalCwd);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
fail(errorCode(error) === "ENOENT" ? "dir-not-found" : "dir-not-readable");
|
|
238
|
+
}
|
|
239
|
+
const preReadFindings = [];
|
|
240
|
+
const kept = [];
|
|
241
|
+
for (const candidate of candidates) {
|
|
242
|
+
const displayPath = normalizeDisplayPath(candidate, roots.canonicalCwd);
|
|
243
|
+
try {
|
|
244
|
+
const link = await lstat(candidate);
|
|
245
|
+
if (link.isSymbolicLink() || !link.isFile()) {
|
|
246
|
+
preReadFindings.push(statError(displayPath));
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const real = await realpath(candidate);
|
|
250
|
+
if (!isContained(roots.canonicalDir, real)) {
|
|
251
|
+
preReadFindings.push(statError(displayPath));
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const info = await stat(candidate, { bigint: true });
|
|
255
|
+
if (Number(info.size) > options.maxSourceBytes) {
|
|
256
|
+
preReadFindings.push(tooLarge(displayPath));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
kept.push({
|
|
260
|
+
absolutePath: candidate,
|
|
261
|
+
displayPath,
|
|
262
|
+
identity: { dev: info.dev, ino: info.ino, size: info.size, mtimeNs: info.mtimeNs }
|
|
263
|
+
});
|
|
264
|
+
} catch {
|
|
265
|
+
preReadFindings.push(statError(displayPath));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
let records = [];
|
|
269
|
+
let lintFindings = [];
|
|
270
|
+
if (kept.length > 0) {
|
|
271
|
+
const result = await lintCorpus({ paths: kept.map((k) => k.absolutePath), cwd: roots.canonicalCwd });
|
|
272
|
+
records = result.records;
|
|
273
|
+
lintFindings = result.findings;
|
|
274
|
+
}
|
|
275
|
+
const postRoots = await verifyRoots(options);
|
|
276
|
+
if (postRoots.canonicalDir !== roots.canonicalDir)
|
|
277
|
+
fail("corpus-changed-during-load");
|
|
278
|
+
for (const candidate of kept) {
|
|
279
|
+
try {
|
|
280
|
+
const link = await lstat(candidate.absolutePath);
|
|
281
|
+
if (link.isSymbolicLink() || !link.isFile())
|
|
282
|
+
fail("corpus-changed-during-load");
|
|
283
|
+
const real = await realpath(candidate.absolutePath);
|
|
284
|
+
if (!isContained(postRoots.canonicalDir, real))
|
|
285
|
+
fail("corpus-changed-during-load");
|
|
286
|
+
const info = await stat(candidate.absolutePath, { bigint: true });
|
|
287
|
+
if (Number(info.size) > options.maxSourceBytes)
|
|
288
|
+
fail("corpus-changed-during-load");
|
|
289
|
+
if (info.dev !== candidate.identity.dev || info.ino !== candidate.identity.ino || info.size !== candidate.identity.size || info.mtimeNs !== candidate.identity.mtimeNs) {
|
|
290
|
+
fail("corpus-changed-during-load");
|
|
291
|
+
}
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (error instanceof CorpusUnavailableError)
|
|
294
|
+
throw error;
|
|
295
|
+
fail("corpus-changed-during-load");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const orderedRecords = [...records].sort((a, b) => compareCodeUnits(a.frontmatter.id, b.frontmatter.id) || compareCodeUnits(a.path, b.path));
|
|
299
|
+
const byId = new Map;
|
|
300
|
+
for (const record of orderedRecords) {
|
|
301
|
+
const bucket = byId.get(record.frontmatter.id) ?? [];
|
|
302
|
+
bucket.push(record);
|
|
303
|
+
byId.set(record.frontmatter.id, bucket);
|
|
304
|
+
}
|
|
305
|
+
const corpusFindings = sortFindingsCanonical([...lintFindings, ...preReadFindings]);
|
|
306
|
+
const recordCount = orderedRecords.length;
|
|
307
|
+
const excludedCount = candidates.length - recordCount;
|
|
308
|
+
const fingerprint = fingerprintOf(orderedRecords, corpusFindings, recordCount, excludedCount);
|
|
309
|
+
return {
|
|
310
|
+
records: orderedRecords,
|
|
311
|
+
byId,
|
|
312
|
+
corpusFindings,
|
|
313
|
+
fingerprint,
|
|
314
|
+
recordCount,
|
|
315
|
+
excludedCount
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// src/tools/shared.ts
|
|
320
|
+
var LIMITS = {
|
|
321
|
+
query: { min: 1, max: 256 },
|
|
322
|
+
ref: { min: 1, max: 128 },
|
|
323
|
+
status: { min: 1, max: 6 },
|
|
324
|
+
scope: { min: 1, max: 3 },
|
|
325
|
+
tags: { min: 1, max: 32 },
|
|
326
|
+
tag: { min: 1, max: 64 },
|
|
327
|
+
fileLength: { min: 1, max: 1024 },
|
|
328
|
+
files: { min: 1, max: 256 },
|
|
329
|
+
page: { min: 1, max: 100, default: 20 },
|
|
330
|
+
cursorBytes: 4 * 1024,
|
|
331
|
+
textCap: 512
|
|
332
|
+
};
|
|
333
|
+
var ANNOTATIONS = {
|
|
334
|
+
readOnlyHint: true,
|
|
335
|
+
destructiveHint: false,
|
|
336
|
+
idempotentHint: true,
|
|
337
|
+
openWorldHint: false
|
|
338
|
+
};
|
|
339
|
+
var INVALID_CURSOR_MESSAGES = {
|
|
340
|
+
"decode-failed": "Cursor could not be decoded.",
|
|
341
|
+
"version-unsupported": "Cursor version is not supported.",
|
|
342
|
+
"wrong-channel": "Cursor does not belong to this tool channel.",
|
|
343
|
+
"corpus-changed": "Corpus changed after this cursor was issued.",
|
|
344
|
+
"query-mismatch": "Cursor was issued for different request parameters.",
|
|
345
|
+
"cursor-not-applicable": "Cursor does not apply to this outcome.",
|
|
346
|
+
"offset-out-of-range": "Cursor offset is outside the current result set."
|
|
347
|
+
};
|
|
348
|
+
var CORPUS_UNAVAILABLE_MESSAGES = {
|
|
349
|
+
"root-not-found": "Configured repository root was not found.",
|
|
350
|
+
"root-not-directory": "Configured repository root is not a directory.",
|
|
351
|
+
"root-not-readable": "Configured repository root is not readable.",
|
|
352
|
+
"root-not-git": "Configured repository root is not a Git worktree.",
|
|
353
|
+
"dir-not-found": "Configured ADR directory was not found.",
|
|
354
|
+
"dir-not-directory": "Configured ADR directory is not a directory.",
|
|
355
|
+
"dir-not-readable": "Configured ADR directory is not readable.",
|
|
356
|
+
"dir-outside-root": "Configured ADR directory resolves outside the repository root.",
|
|
357
|
+
"corpus-changed-during-load": "Corpus changed while it was being loaded; retry the call."
|
|
358
|
+
};
|
|
359
|
+
var INVALID_CURSOR_REASONS = Object.keys(INVALID_CURSOR_MESSAGES);
|
|
360
|
+
var CORPUS_UNAVAILABLE_REASONS = Object.keys(CORPUS_UNAVAILABLE_MESSAGES);
|
|
361
|
+
function plural(n, one, many) {
|
|
362
|
+
return n === 1 ? one : many;
|
|
363
|
+
}
|
|
364
|
+
function cap512(value) {
|
|
365
|
+
return value.length > LIMITS.textCap ? value.slice(0, LIMITS.textCap) : value;
|
|
366
|
+
}
|
|
367
|
+
function findingsPhrase(findings) {
|
|
368
|
+
return `${findings} ${plural(findings, "finding", "findings")} on this page.`;
|
|
369
|
+
}
|
|
370
|
+
function renderResponseText(spec) {
|
|
371
|
+
let text;
|
|
372
|
+
switch (spec.kind) {
|
|
373
|
+
case "results":
|
|
374
|
+
text = `Returned ${spec.itemsLength} decision ${plural(spec.itemsLength, "result", "results")}; ${findingsPhrase(spec.findings)}`;
|
|
375
|
+
break;
|
|
376
|
+
case "found":
|
|
377
|
+
text = `Found decision "${spec.id}"; ${findingsPhrase(spec.findings)}`;
|
|
378
|
+
break;
|
|
379
|
+
case "not-found":
|
|
380
|
+
text = `No local decision matches "${spec.requestedRef}"; ${findingsPhrase(spec.findings)}`;
|
|
381
|
+
break;
|
|
382
|
+
case "ambiguous-local-id":
|
|
383
|
+
text = `Returned ${spec.candidatesLength} ${plural(spec.candidatesLength, "candidate", "candidates")} for ambiguous local ref "${spec.requestedRef}"; ${findingsPhrase(spec.findings)}`;
|
|
384
|
+
break;
|
|
385
|
+
case "federated-log-unavailable":
|
|
386
|
+
text = `Named-log federation is unavailable for "${spec.requestedRef}"; ${findingsPhrase(spec.findings)}`;
|
|
387
|
+
break;
|
|
388
|
+
case "matches": {
|
|
389
|
+
const pageMatchCount = spec.governing + spec.activeProposals + spec.history;
|
|
390
|
+
text = `Returned ${pageMatchCount} context ${plural(pageMatchCount, "match", "matches")}: ${spec.governing} governing, ${spec.activeProposals} active ${plural(spec.activeProposals, "proposal", "proposals")}, ${spec.history} historical; ${findingsPhrase(spec.findings)}`;
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
case "entries":
|
|
394
|
+
text = `Returned ${spec.itemsLength} superseded decision ${plural(spec.itemsLength, "entry", "entries")}; ${findingsPhrase(spec.findings)}`;
|
|
395
|
+
break;
|
|
396
|
+
case "invalid-cursor":
|
|
397
|
+
text = INVALID_CURSOR_MESSAGES[spec.reason];
|
|
398
|
+
break;
|
|
399
|
+
case "corpus-unavailable":
|
|
400
|
+
text = CORPUS_UNAVAILABLE_MESSAGES[spec.reason];
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
return cap512(text);
|
|
404
|
+
}
|
|
405
|
+
var uniqueArray = (schema, min, max) => z.array(schema).min(min).max(max).refine((values) => new Set(values.map((v) => JSON.stringify(v))).size === values.length, {
|
|
406
|
+
message: "Items must be unique"
|
|
407
|
+
});
|
|
408
|
+
function paginationShape() {
|
|
409
|
+
const cursor = z.string().max(LIMITS.cursorBytes).optional();
|
|
410
|
+
const limit = z.number().int().min(LIMITS.page.min).max(LIMITS.page.max).default(LIMITS.page.default);
|
|
411
|
+
return { cursor, limit, findingsCursor: cursor, findingsLimit: limit };
|
|
412
|
+
}
|
|
413
|
+
function isSafePosixPath(path) {
|
|
414
|
+
if (path.includes("\\"))
|
|
415
|
+
return false;
|
|
416
|
+
if (path.startsWith("/"))
|
|
417
|
+
return false;
|
|
418
|
+
if (/^[a-zA-Z]:/.test(path))
|
|
419
|
+
return false;
|
|
420
|
+
if (path.split("/").includes(".."))
|
|
421
|
+
return false;
|
|
422
|
+
return path.length > 0;
|
|
423
|
+
}
|
|
424
|
+
function searchDecisionsInputSchema() {
|
|
425
|
+
return z.strictObject({
|
|
426
|
+
query: z.string().min(LIMITS.query.min).max(LIMITS.query.max).refine((q) => q.trim().length >= 1, { message: "query must be non-empty after trimming" }),
|
|
427
|
+
status: uniqueArray(Status, LIMITS.status.min, LIMITS.status.max).optional(),
|
|
428
|
+
tags: uniqueArray(z.string().min(LIMITS.tag.min).max(LIMITS.tag.max), LIMITS.tags.min, LIMITS.tags.max).optional(),
|
|
429
|
+
scope: uniqueArray(Scope, LIMITS.scope.min, LIMITS.scope.max).optional(),
|
|
430
|
+
...paginationShape()
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
function getDecisionInputSchema() {
|
|
434
|
+
return z.strictObject({
|
|
435
|
+
ref: AdrRef.max(LIMITS.ref.max),
|
|
436
|
+
...paginationShape()
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
function getDecisionContextInputSchema() {
|
|
440
|
+
return z.strictObject({
|
|
441
|
+
files: z.array(z.string().min(LIMITS.fileLength.min).max(LIMITS.fileLength.max).refine(isSafePosixPath, { message: "files must be repo-relative POSIX paths" })).min(LIMITS.files.min).max(LIMITS.files.max),
|
|
442
|
+
...paginationShape()
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
function listSupersededInputSchema() {
|
|
446
|
+
return z.strictObject({ ...paginationShape() });
|
|
447
|
+
}
|
|
448
|
+
function findingSchema() {
|
|
449
|
+
return z.object({
|
|
450
|
+
rule: z.string(),
|
|
451
|
+
severity: z.enum(["error", "warn", "info"]),
|
|
452
|
+
message: z.string(),
|
|
453
|
+
path: z.string().optional(),
|
|
454
|
+
id: z.string().optional(),
|
|
455
|
+
field: z.string().optional(),
|
|
456
|
+
pattern: z.string().optional()
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
function corpusHealthSchema() {
|
|
460
|
+
return z.object({
|
|
461
|
+
fingerprint: z.string(),
|
|
462
|
+
recordCount: z.number().int(),
|
|
463
|
+
excludedCount: z.number().int()
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
function findingsPageSchema() {
|
|
467
|
+
return z.object({ items: z.array(findingSchema()), cursor: z.string().nullable() });
|
|
468
|
+
}
|
|
469
|
+
function decisionSummarySchema() {
|
|
470
|
+
return z.object({ id: z.string(), title: z.string(), status: Status, sourcePath: z.string() });
|
|
471
|
+
}
|
|
472
|
+
function relationRefsSchema() {
|
|
473
|
+
return z.object({
|
|
474
|
+
supersedes: z.array(z.string()),
|
|
475
|
+
supersededBy: z.string().nullable(),
|
|
476
|
+
relatesTo: z.array(z.string()),
|
|
477
|
+
conflictsWith: z.array(z.string())
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
function invalidCursorSchema() {
|
|
481
|
+
return z.object({
|
|
482
|
+
outcome: z.literal("invalid-cursor"),
|
|
483
|
+
reason: z.enum(INVALID_CURSOR_REASONS),
|
|
484
|
+
message: z.string()
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
function corpusUnavailableSchema() {
|
|
488
|
+
return z.object({
|
|
489
|
+
outcome: z.literal("corpus-unavailable"),
|
|
490
|
+
reason: z.enum(CORPUS_UNAVAILABLE_REASONS),
|
|
491
|
+
message: z.string()
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
function searchDecisionsOutputSchema() {
|
|
495
|
+
const searchMatch = z.object({
|
|
496
|
+
id: z.string(),
|
|
497
|
+
title: z.string(),
|
|
498
|
+
status: Status,
|
|
499
|
+
sourcePath: z.string(),
|
|
500
|
+
matchedFields: z.array(z.enum(["id", "title", "tag", "body"]))
|
|
501
|
+
});
|
|
502
|
+
return {
|
|
503
|
+
corpusHealth: corpusHealthSchema().optional(),
|
|
504
|
+
result: z.discriminatedUnion("outcome", [
|
|
505
|
+
z.object({
|
|
506
|
+
outcome: z.literal("results"),
|
|
507
|
+
items: z.array(searchMatch),
|
|
508
|
+
cursor: z.string().nullable(),
|
|
509
|
+
findings: findingsPageSchema()
|
|
510
|
+
}),
|
|
511
|
+
invalidCursorSchema(),
|
|
512
|
+
corpusUnavailableSchema()
|
|
513
|
+
])
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function getDecisionOutputSchema() {
|
|
517
|
+
const fullDecision = z.object({
|
|
518
|
+
requestedRef: z.string(),
|
|
519
|
+
id: z.string(),
|
|
520
|
+
title: z.string(),
|
|
521
|
+
status: Status,
|
|
522
|
+
sourcePath: z.string(),
|
|
523
|
+
frontmatter: AdrFrontmatter,
|
|
524
|
+
body: z.string()
|
|
525
|
+
});
|
|
526
|
+
return {
|
|
527
|
+
corpusHealth: corpusHealthSchema().optional(),
|
|
528
|
+
result: z.discriminatedUnion("outcome", [
|
|
529
|
+
z.object({ outcome: z.literal("found"), decision: fullDecision, findings: findingsPageSchema() }),
|
|
530
|
+
z.object({ outcome: z.literal("not-found"), requestedRef: z.string(), findings: findingsPageSchema() }),
|
|
531
|
+
z.object({
|
|
532
|
+
outcome: z.literal("ambiguous-local-id"),
|
|
533
|
+
requestedRef: z.string(),
|
|
534
|
+
candidates: z.array(decisionSummarySchema()),
|
|
535
|
+
cursor: z.string().nullable(),
|
|
536
|
+
findings: findingsPageSchema()
|
|
537
|
+
}),
|
|
538
|
+
z.object({
|
|
539
|
+
outcome: z.literal("federated-log-unavailable"),
|
|
540
|
+
requestedRef: z.string(),
|
|
541
|
+
log: z.string(),
|
|
542
|
+
id: z.string(),
|
|
543
|
+
findings: findingsPageSchema()
|
|
544
|
+
}),
|
|
545
|
+
invalidCursorSchema(),
|
|
546
|
+
corpusUnavailableSchema()
|
|
547
|
+
])
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function getDecisionContextOutputSchema() {
|
|
551
|
+
const contextEntry = z.object({
|
|
552
|
+
id: z.string(),
|
|
553
|
+
title: z.string(),
|
|
554
|
+
status: Status,
|
|
555
|
+
sourcePath: z.string(),
|
|
556
|
+
firedMatchers: z.array(z.object({ type: z.string(), pattern: z.string() })),
|
|
557
|
+
relations: relationRefsSchema()
|
|
558
|
+
});
|
|
559
|
+
return {
|
|
560
|
+
corpusHealth: corpusHealthSchema().optional(),
|
|
561
|
+
result: z.discriminatedUnion("outcome", [
|
|
562
|
+
z.object({
|
|
563
|
+
outcome: z.literal("matches"),
|
|
564
|
+
governing: z.array(contextEntry),
|
|
565
|
+
activeProposals: z.array(contextEntry),
|
|
566
|
+
history: z.array(contextEntry),
|
|
567
|
+
cursor: z.string().nullable(),
|
|
568
|
+
findings: findingsPageSchema()
|
|
569
|
+
}),
|
|
570
|
+
invalidCursorSchema(),
|
|
571
|
+
corpusUnavailableSchema()
|
|
572
|
+
])
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
function listSupersededOutputSchema() {
|
|
576
|
+
const supersededBy = z.union([
|
|
577
|
+
z.object({ resolved: z.literal(true), target: decisionSummarySchema() }),
|
|
578
|
+
z.object({ resolved: z.literal(false), targetRef: z.string(), reason: z.literal("dangling") }),
|
|
579
|
+
z.object({
|
|
580
|
+
resolved: z.literal(false),
|
|
581
|
+
targetRef: z.string(),
|
|
582
|
+
reason: z.literal("ambiguous"),
|
|
583
|
+
candidateCount: z.number().int()
|
|
584
|
+
}),
|
|
585
|
+
z.object({
|
|
586
|
+
resolved: z.literal(false),
|
|
587
|
+
targetRef: z.string(),
|
|
588
|
+
reason: z.literal("federated-unavailable"),
|
|
589
|
+
log: z.string(),
|
|
590
|
+
id: z.string()
|
|
591
|
+
})
|
|
592
|
+
]);
|
|
593
|
+
const supersededEntry = z.object({
|
|
594
|
+
id: z.string(),
|
|
595
|
+
title: z.string(),
|
|
596
|
+
status: Status,
|
|
597
|
+
sourcePath: z.string(),
|
|
598
|
+
supersededBy
|
|
599
|
+
});
|
|
600
|
+
return {
|
|
601
|
+
corpusHealth: corpusHealthSchema().optional(),
|
|
602
|
+
result: z.discriminatedUnion("outcome", [
|
|
603
|
+
z.object({
|
|
604
|
+
outcome: z.literal("entries"),
|
|
605
|
+
items: z.array(supersededEntry),
|
|
606
|
+
cursor: z.string().nullable(),
|
|
607
|
+
findings: findingsPageSchema()
|
|
608
|
+
}),
|
|
609
|
+
invalidCursorSchema(),
|
|
610
|
+
corpusUnavailableSchema()
|
|
611
|
+
])
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
function structuredResult(result, text, corpusHealth) {
|
|
615
|
+
const structuredContent = corpusHealth === undefined ? { result } : { corpusHealth, result };
|
|
616
|
+
return { structuredContent, content: [{ type: "text", text }] };
|
|
617
|
+
}
|
|
618
|
+
function corpusHealthOf(projection) {
|
|
619
|
+
return {
|
|
620
|
+
fingerprint: projection.fingerprint,
|
|
621
|
+
recordCount: projection.recordCount,
|
|
622
|
+
excludedCount: projection.excludedCount
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
function invalidCursor(reason) {
|
|
626
|
+
return { outcome: "invalid-cursor", reason, message: INVALID_CURSOR_MESSAGES[reason] };
|
|
627
|
+
}
|
|
628
|
+
function corpusUnavailable(reason) {
|
|
629
|
+
return { outcome: "corpus-unavailable", reason, message: CORPUS_UNAVAILABLE_MESSAGES[reason] };
|
|
630
|
+
}
|
|
631
|
+
async function loadProjection(config) {
|
|
632
|
+
try {
|
|
633
|
+
const projection = await loadCorpusProjection({
|
|
634
|
+
configuredCwd: config.configuredCwd,
|
|
635
|
+
configuredDir: config.configuredDir,
|
|
636
|
+
expectedCanonicalCwd: config.expectedCanonicalCwd,
|
|
637
|
+
maxSourceBytes: config.maxSourceBytes
|
|
638
|
+
});
|
|
639
|
+
return { ok: true, projection };
|
|
640
|
+
} catch (error) {
|
|
641
|
+
if (error instanceof CorpusUnavailableError)
|
|
642
|
+
return { ok: false, outcome: corpusUnavailable(error.reason) };
|
|
643
|
+
throw error;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
function toSummary(record) {
|
|
647
|
+
return {
|
|
648
|
+
id: record.frontmatter.id,
|
|
649
|
+
title: record.frontmatter.title,
|
|
650
|
+
status: record.frontmatter.status,
|
|
651
|
+
sourcePath: record.path
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
function toRelationRefs(frontmatter) {
|
|
655
|
+
return {
|
|
656
|
+
supersedes: frontmatter.supersedes,
|
|
657
|
+
supersededBy: frontmatter.supersededBy ?? null,
|
|
658
|
+
relatesTo: frontmatter.relatesTo,
|
|
659
|
+
conflictsWith: frontmatter.conflictsWith
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// src/tools/search-decisions.ts
|
|
664
|
+
function sortedUnique(values) {
|
|
665
|
+
return values ? [...new Set(values)].sort(compareCodeUnits) : [];
|
|
666
|
+
}
|
|
667
|
+
function passesFilters(record, status, scope, tags) {
|
|
668
|
+
if (status && !status.includes(record.frontmatter.status))
|
|
669
|
+
return false;
|
|
670
|
+
if (scope && !scope.includes(record.frontmatter.scope))
|
|
671
|
+
return false;
|
|
672
|
+
if (tags && !tags.every((tag) => record.frontmatter.tags.includes(tag)))
|
|
673
|
+
return false;
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
function matchedFields(record, needle) {
|
|
677
|
+
const fields = [];
|
|
678
|
+
if (normalize(record.body).includes(needle))
|
|
679
|
+
fields.push("body");
|
|
680
|
+
if (normalize(record.frontmatter.id).includes(needle))
|
|
681
|
+
fields.push("id");
|
|
682
|
+
if (record.frontmatter.tags.some((tag) => normalize(tag).includes(needle)))
|
|
683
|
+
fields.push("tag");
|
|
684
|
+
if (normalize(record.frontmatter.title).includes(needle))
|
|
685
|
+
fields.push("title");
|
|
686
|
+
return fields;
|
|
687
|
+
}
|
|
688
|
+
function registerSearchDecisions(server, config) {
|
|
689
|
+
server.registerTool("search_decisions", {
|
|
690
|
+
title: "Search decisions",
|
|
691
|
+
description: "Search the decision corpus (including the graveyard by default) by deterministic normalized literal substring over id, title, tags, and Markdown body. Optional status/scope (any-of) and tags (all-of) filters are ANDed. Returns bounded summaries only — no ranking, no model, no body.",
|
|
692
|
+
inputSchema: searchDecisionsInputSchema(),
|
|
693
|
+
outputSchema: searchDecisionsOutputSchema(),
|
|
694
|
+
annotations: ANNOTATIONS
|
|
695
|
+
}, async (args) => {
|
|
696
|
+
const { query, status, tags, scope, cursor, limit, findingsCursor, findingsLimit } = args;
|
|
697
|
+
const loaded = await loadProjection(config);
|
|
698
|
+
if (!loaded.ok) {
|
|
699
|
+
return structuredResult(loaded.outcome, renderResponseText({ kind: "corpus-unavailable", reason: loaded.outcome.reason }), undefined);
|
|
700
|
+
}
|
|
701
|
+
const projection = loaded.projection;
|
|
702
|
+
const health = corpusHealthOf(projection);
|
|
703
|
+
const fp = projection.fingerprint;
|
|
704
|
+
const needle = normalize(query);
|
|
705
|
+
const primaryQh = queryShapeHash([needle, sortedUnique(status), sortedUnique(tags), sortedUnique(scope), limit]);
|
|
706
|
+
const findingsQh = queryShapeHash([findingsLimit]);
|
|
707
|
+
const matches = [];
|
|
708
|
+
for (const record of projection.records) {
|
|
709
|
+
if (!passesFilters(record, status, scope, tags))
|
|
710
|
+
continue;
|
|
711
|
+
const fields = matchedFields(record, needle);
|
|
712
|
+
if (fields.length === 0)
|
|
713
|
+
continue;
|
|
714
|
+
matches.push({ ...toSummary(record), matchedFields: fields });
|
|
715
|
+
}
|
|
716
|
+
const primary = paginate({ items: matches, cursor, limit, scope: "search.results", fp, qh: primaryQh });
|
|
717
|
+
if (!primary.ok)
|
|
718
|
+
return invalid(primary.reason);
|
|
719
|
+
const findingsPage = paginate({
|
|
720
|
+
items: projection.corpusFindings,
|
|
721
|
+
cursor: findingsCursor,
|
|
722
|
+
limit: findingsLimit,
|
|
723
|
+
scope: "search.findings",
|
|
724
|
+
fp,
|
|
725
|
+
qh: findingsQh
|
|
726
|
+
});
|
|
727
|
+
if (!findingsPage.ok)
|
|
728
|
+
return invalid(findingsPage.reason);
|
|
729
|
+
const result = {
|
|
730
|
+
outcome: "results",
|
|
731
|
+
items: primary.page.items,
|
|
732
|
+
cursor: primary.page.cursor,
|
|
733
|
+
findings: findingsPage.page
|
|
734
|
+
};
|
|
735
|
+
return structuredResult(result, renderResponseText({ kind: "results", itemsLength: primary.page.items.length, findings: findingsPage.page.items.length }), health);
|
|
736
|
+
function invalid(reason) {
|
|
737
|
+
return structuredResult(invalidCursor(reason), renderResponseText({ kind: "invalid-cursor", reason }), health);
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// src/tools/get-decision.ts
|
|
743
|
+
import { parseAdrRef } from "@adrkit/core";
|
|
744
|
+
function fullDecision(record, ref) {
|
|
745
|
+
return {
|
|
746
|
+
requestedRef: ref,
|
|
747
|
+
id: record.frontmatter.id,
|
|
748
|
+
title: record.frontmatter.title,
|
|
749
|
+
status: record.frontmatter.status,
|
|
750
|
+
sourcePath: record.path,
|
|
751
|
+
frontmatter: record.frontmatter,
|
|
752
|
+
body: record.body
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
function registerGetDecision(server, config) {
|
|
756
|
+
server.registerTool("get_decision", {
|
|
757
|
+
title: "Get a decision by ref",
|
|
758
|
+
description: 'Fetch one architecture decision by its bare local id (e.g. "0042"). Returns the complete typed frontmatter and Markdown body, or an explicit not-found / ambiguous-local-id / federated-log-unavailable outcome. Relation refs are surfaced verbatim, never expanded.',
|
|
759
|
+
inputSchema: getDecisionInputSchema(),
|
|
760
|
+
outputSchema: getDecisionOutputSchema(),
|
|
761
|
+
annotations: ANNOTATIONS
|
|
762
|
+
}, async (args) => {
|
|
763
|
+
const { ref, cursor, limit, findingsCursor, findingsLimit } = args;
|
|
764
|
+
const loaded = await loadProjection(config);
|
|
765
|
+
if (!loaded.ok) {
|
|
766
|
+
return structuredResult(loaded.outcome, renderResponseText({ kind: "corpus-unavailable", reason: loaded.outcome.reason }), undefined);
|
|
767
|
+
}
|
|
768
|
+
const projection = loaded.projection;
|
|
769
|
+
const health = corpusHealthOf(projection);
|
|
770
|
+
const fp = projection.fingerprint;
|
|
771
|
+
const responseFindings = projection.corpusFindings;
|
|
772
|
+
const findingsPage = paginate({
|
|
773
|
+
items: responseFindings,
|
|
774
|
+
cursor: findingsCursor,
|
|
775
|
+
limit: findingsLimit,
|
|
776
|
+
scope: "get_decision.findings",
|
|
777
|
+
fp,
|
|
778
|
+
qh: queryShapeHash([findingsLimit])
|
|
779
|
+
});
|
|
780
|
+
const parsed = parseAdrRef(ref);
|
|
781
|
+
const candidatesQh = queryShapeHash([ref, limit]);
|
|
782
|
+
if (parsed.log !== undefined) {
|
|
783
|
+
const inapplicable2 = checkInapplicablePrimaryCursor({ cursor, scope: "get_decision.candidates", fp, qh: candidatesQh });
|
|
784
|
+
if (!inapplicable2.ok)
|
|
785
|
+
return invalidResult(inapplicable2.reason, health);
|
|
786
|
+
if (!findingsPage.ok)
|
|
787
|
+
return invalidResult(findingsPage.reason, health);
|
|
788
|
+
const result2 = {
|
|
789
|
+
outcome: "federated-log-unavailable",
|
|
790
|
+
requestedRef: ref,
|
|
791
|
+
log: parsed.log,
|
|
792
|
+
id: parsed.id,
|
|
793
|
+
findings: findingsPage.page
|
|
794
|
+
};
|
|
795
|
+
return structuredResult(result2, renderResponseText({ kind: "federated-log-unavailable", requestedRef: ref, findings: findingsPage.page.items.length }), health);
|
|
796
|
+
}
|
|
797
|
+
const bucket = projection.byId.get(parsed.id) ?? [];
|
|
798
|
+
if (bucket.length > 1) {
|
|
799
|
+
const candidates = bucket.map(toSummary);
|
|
800
|
+
const primary = paginate({
|
|
801
|
+
items: candidates,
|
|
802
|
+
cursor,
|
|
803
|
+
limit,
|
|
804
|
+
scope: "get_decision.candidates",
|
|
805
|
+
fp,
|
|
806
|
+
qh: candidatesQh
|
|
807
|
+
});
|
|
808
|
+
if (!primary.ok)
|
|
809
|
+
return invalidResult(primary.reason, health);
|
|
810
|
+
if (!findingsPage.ok)
|
|
811
|
+
return invalidResult(findingsPage.reason, health);
|
|
812
|
+
const result2 = {
|
|
813
|
+
outcome: "ambiguous-local-id",
|
|
814
|
+
requestedRef: ref,
|
|
815
|
+
candidates: primary.page.items,
|
|
816
|
+
cursor: primary.page.cursor,
|
|
817
|
+
findings: findingsPage.page
|
|
818
|
+
};
|
|
819
|
+
return structuredResult(result2, renderResponseText({
|
|
820
|
+
kind: "ambiguous-local-id",
|
|
821
|
+
candidatesLength: primary.page.items.length,
|
|
822
|
+
requestedRef: ref,
|
|
823
|
+
findings: findingsPage.page.items.length
|
|
824
|
+
}), health);
|
|
825
|
+
}
|
|
826
|
+
const inapplicable = checkInapplicablePrimaryCursor({ cursor, scope: "get_decision.candidates", fp, qh: candidatesQh });
|
|
827
|
+
if (!inapplicable.ok)
|
|
828
|
+
return invalidResult(inapplicable.reason, health);
|
|
829
|
+
if (!findingsPage.ok)
|
|
830
|
+
return invalidResult(findingsPage.reason, health);
|
|
831
|
+
if (bucket.length === 1) {
|
|
832
|
+
const record = bucket[0];
|
|
833
|
+
const result2 = { outcome: "found", decision: fullDecision(record, ref), findings: findingsPage.page };
|
|
834
|
+
return structuredResult(result2, renderResponseText({ kind: "found", id: record.frontmatter.id, findings: findingsPage.page.items.length }), health);
|
|
835
|
+
}
|
|
836
|
+
const result = { outcome: "not-found", requestedRef: ref, findings: findingsPage.page };
|
|
837
|
+
return structuredResult(result, renderResponseText({ kind: "not-found", requestedRef: ref, findings: findingsPage.page.items.length }), health);
|
|
838
|
+
function invalidResult(reason, corpusHealth) {
|
|
839
|
+
const outcome = invalidCursor(reason);
|
|
840
|
+
return structuredResult(outcome, renderResponseText({ kind: "invalid-cursor", reason }), corpusHealth);
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// src/tools/get-decision-context.ts
|
|
846
|
+
import { resolveAffects } from "@adrkit/core";
|
|
847
|
+
function bucketFor(status) {
|
|
848
|
+
if (status === "accepted")
|
|
849
|
+
return "governing";
|
|
850
|
+
if (status === "draft" || status === "proposed")
|
|
851
|
+
return "activeProposals";
|
|
852
|
+
return "history";
|
|
853
|
+
}
|
|
854
|
+
function contextEntry(record, firedMatchers) {
|
|
855
|
+
return { ...toSummary(record), firedMatchers, relations: toRelationRefs(record.frontmatter) };
|
|
856
|
+
}
|
|
857
|
+
function registerGetDecisionContext(server, config) {
|
|
858
|
+
server.registerTool("get_decision_context", {
|
|
859
|
+
title: "Get decision context for files",
|
|
860
|
+
description: "Given repo-relative logical file paths, report which decisions govern them, which active proposals touch them, and which historical records once did — using the corpus’s own affects matchers. The supplied paths are compared against patterns only; they are never opened or read.",
|
|
861
|
+
inputSchema: getDecisionContextInputSchema(),
|
|
862
|
+
outputSchema: getDecisionContextOutputSchema(),
|
|
863
|
+
annotations: ANNOTATIONS
|
|
864
|
+
}, async (args) => {
|
|
865
|
+
const { files, cursor, limit, findingsCursor, findingsLimit } = args;
|
|
866
|
+
const loaded = await loadProjection(config);
|
|
867
|
+
if (!loaded.ok) {
|
|
868
|
+
return structuredResult(loaded.outcome, renderResponseText({ kind: "corpus-unavailable", reason: loaded.outcome.reason }), undefined);
|
|
869
|
+
}
|
|
870
|
+
const projection = loaded.projection;
|
|
871
|
+
const health = corpusHealthOf(projection);
|
|
872
|
+
const fp = projection.fingerprint;
|
|
873
|
+
const canonicalFiles = [...new Set(files)].sort(compareCodeUnits);
|
|
874
|
+
const primaryQh = queryShapeHash([canonicalFiles, limit]);
|
|
875
|
+
const findingsQh = queryShapeHash([canonicalFiles, findingsLimit]);
|
|
876
|
+
const flat = [];
|
|
877
|
+
const derivedFindings = [];
|
|
878
|
+
for (const record of projection.records) {
|
|
879
|
+
const resolved = resolveAffects({ records: [record], changedFiles: files });
|
|
880
|
+
derivedFindings.push(...resolved.findings);
|
|
881
|
+
const match = resolved.matches[0];
|
|
882
|
+
if (match)
|
|
883
|
+
flat.push(contextEntry(record, match.firedMatchers));
|
|
884
|
+
}
|
|
885
|
+
const responseFindings = sortFindingsCanonical([...projection.corpusFindings, ...derivedFindings]);
|
|
886
|
+
const primary = paginate({ items: flat, cursor, limit, scope: "context.results", fp, qh: primaryQh });
|
|
887
|
+
if (!primary.ok)
|
|
888
|
+
return invalid(primary.reason);
|
|
889
|
+
const findingsPage = paginate({
|
|
890
|
+
items: responseFindings,
|
|
891
|
+
cursor: findingsCursor,
|
|
892
|
+
limit: findingsLimit,
|
|
893
|
+
scope: "context.findings",
|
|
894
|
+
fp,
|
|
895
|
+
qh: findingsQh
|
|
896
|
+
});
|
|
897
|
+
if (!findingsPage.ok)
|
|
898
|
+
return invalid(findingsPage.reason);
|
|
899
|
+
const governing = [];
|
|
900
|
+
const activeProposals = [];
|
|
901
|
+
const history = [];
|
|
902
|
+
for (const entry of primary.page.items) {
|
|
903
|
+
const target = bucketFor(entry.status) === "governing" ? governing : bucketFor(entry.status) === "activeProposals" ? activeProposals : history;
|
|
904
|
+
target.push(entry);
|
|
905
|
+
}
|
|
906
|
+
const result = {
|
|
907
|
+
outcome: "matches",
|
|
908
|
+
governing,
|
|
909
|
+
activeProposals,
|
|
910
|
+
history,
|
|
911
|
+
cursor: primary.page.cursor,
|
|
912
|
+
findings: findingsPage.page
|
|
913
|
+
};
|
|
914
|
+
return structuredResult(result, renderResponseText({
|
|
915
|
+
kind: "matches",
|
|
916
|
+
governing: governing.length,
|
|
917
|
+
activeProposals: activeProposals.length,
|
|
918
|
+
history: history.length,
|
|
919
|
+
findings: findingsPage.page.items.length
|
|
920
|
+
}), health);
|
|
921
|
+
function invalid(reason) {
|
|
922
|
+
return structuredResult(invalidCursor(reason), renderResponseText({ kind: "invalid-cursor", reason }), health);
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// src/tools/list-superseded.ts
|
|
928
|
+
import { parseAdrRef as parseAdrRef2 } from "@adrkit/core";
|
|
929
|
+
function resolveEntry(record, byId) {
|
|
930
|
+
const targetRef = record.frontmatter.supersededBy;
|
|
931
|
+
const parsed = parseAdrRef2(targetRef);
|
|
932
|
+
const summary = toSummary(record);
|
|
933
|
+
if (parsed.log !== undefined) {
|
|
934
|
+
const supersededBy2 = {
|
|
935
|
+
resolved: false,
|
|
936
|
+
targetRef,
|
|
937
|
+
reason: "federated-unavailable",
|
|
938
|
+
log: parsed.log,
|
|
939
|
+
id: parsed.id
|
|
940
|
+
};
|
|
941
|
+
const derived2 = {
|
|
942
|
+
rule: "superseded-target-federated-unavailable",
|
|
943
|
+
severity: "info",
|
|
944
|
+
id: record.frontmatter.id,
|
|
945
|
+
message: `supersededBy target "${targetRef}" is a log-qualified ref; named-log federation is not available in this phase`
|
|
946
|
+
};
|
|
947
|
+
return { entry: { ...summary, supersededBy: supersededBy2 }, derived: derived2 };
|
|
948
|
+
}
|
|
949
|
+
const bucket = byId.get(parsed.id) ?? [];
|
|
950
|
+
if (bucket.length === 1) {
|
|
951
|
+
return { entry: { ...summary, supersededBy: { resolved: true, target: toSummary(bucket[0]) } }, derived: undefined };
|
|
952
|
+
}
|
|
953
|
+
if (bucket.length === 0) {
|
|
954
|
+
return { entry: { ...summary, supersededBy: { resolved: false, targetRef, reason: "dangling" } }, derived: undefined };
|
|
955
|
+
}
|
|
956
|
+
const supersededBy = { resolved: false, targetRef, reason: "ambiguous", candidateCount: bucket.length };
|
|
957
|
+
const derived = {
|
|
958
|
+
rule: "superseded-target-ambiguous",
|
|
959
|
+
severity: "warn",
|
|
960
|
+
id: record.frontmatter.id,
|
|
961
|
+
message: `supersededBy target "${targetRef}" resolves to ${bucket.length} local records; see get_decision("${targetRef}") for the full candidate list`
|
|
962
|
+
};
|
|
963
|
+
return { entry: { ...summary, supersededBy }, derived };
|
|
964
|
+
}
|
|
965
|
+
function registerListSuperseded(server, config) {
|
|
966
|
+
server.registerTool("list_superseded", {
|
|
967
|
+
title: "List superseded decisions",
|
|
968
|
+
description: "List every superseded decision with its direct local replacement state: resolved, dangling, ambiguous (candidateCount only), or federated-unavailable. Direct edges only — no transitive lineage, no embedded candidate arrays.",
|
|
969
|
+
inputSchema: listSupersededInputSchema(),
|
|
970
|
+
outputSchema: listSupersededOutputSchema(),
|
|
971
|
+
annotations: ANNOTATIONS
|
|
972
|
+
}, async (args) => {
|
|
973
|
+
const { cursor, limit, findingsCursor, findingsLimit } = args;
|
|
974
|
+
const loaded = await loadProjection(config);
|
|
975
|
+
if (!loaded.ok) {
|
|
976
|
+
return structuredResult(loaded.outcome, renderResponseText({ kind: "corpus-unavailable", reason: loaded.outcome.reason }), undefined);
|
|
977
|
+
}
|
|
978
|
+
const projection = loaded.projection;
|
|
979
|
+
const health = corpusHealthOf(projection);
|
|
980
|
+
const fp = projection.fingerprint;
|
|
981
|
+
const entries = [];
|
|
982
|
+
const derivedFindings = [];
|
|
983
|
+
for (const record of projection.records) {
|
|
984
|
+
if (record.frontmatter.status !== "superseded")
|
|
985
|
+
continue;
|
|
986
|
+
const resolved = resolveEntry(record, projection.byId);
|
|
987
|
+
entries.push(resolved.entry);
|
|
988
|
+
if (resolved.derived)
|
|
989
|
+
derivedFindings.push(resolved.derived);
|
|
990
|
+
}
|
|
991
|
+
const responseFindings = sortFindingsCanonical([...projection.corpusFindings, ...derivedFindings]);
|
|
992
|
+
const primary = paginate({ items: entries, cursor, limit, scope: "superseded.results", fp, qh: queryShapeHash([limit]) });
|
|
993
|
+
if (!primary.ok)
|
|
994
|
+
return invalid(primary.reason);
|
|
995
|
+
const findingsPage = paginate({
|
|
996
|
+
items: responseFindings,
|
|
997
|
+
cursor: findingsCursor,
|
|
998
|
+
limit: findingsLimit,
|
|
999
|
+
scope: "superseded.findings",
|
|
1000
|
+
fp,
|
|
1001
|
+
qh: queryShapeHash([findingsLimit])
|
|
1002
|
+
});
|
|
1003
|
+
if (!findingsPage.ok)
|
|
1004
|
+
return invalid(findingsPage.reason);
|
|
1005
|
+
const result = {
|
|
1006
|
+
outcome: "entries",
|
|
1007
|
+
items: primary.page.items,
|
|
1008
|
+
cursor: primary.page.cursor,
|
|
1009
|
+
findings: findingsPage.page
|
|
1010
|
+
};
|
|
1011
|
+
return structuredResult(result, renderResponseText({ kind: "entries", itemsLength: primary.page.items.length, findings: findingsPage.page.items.length }), health);
|
|
1012
|
+
function invalid(reason) {
|
|
1013
|
+
return structuredResult(invalidCursor(reason), renderResponseText({ kind: "invalid-cursor", reason }), health);
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// src/server.ts
|
|
1019
|
+
var SERVER_INFO = { name: "@adrkit/mcp", version: "0.1.0" };
|
|
1020
|
+
function buildRegisteredServer(config) {
|
|
1021
|
+
const server = new McpServer(SERVER_INFO);
|
|
1022
|
+
registerSearchDecisions(server, config);
|
|
1023
|
+
registerGetDecision(server, config);
|
|
1024
|
+
registerGetDecisionContext(server, config);
|
|
1025
|
+
registerListSuperseded(server, config);
|
|
1026
|
+
return server;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// src/index.ts
|
|
1030
|
+
function createAdrkitMcpServer(options) {
|
|
1031
|
+
const cwd = resolve2(options?.cwd ?? process.cwd());
|
|
1032
|
+
const dir = options?.dir ?? "docs/adr";
|
|
1033
|
+
let server;
|
|
1034
|
+
let startPromise;
|
|
1035
|
+
let closePromise;
|
|
1036
|
+
let closed = false;
|
|
1037
|
+
function start() {
|
|
1038
|
+
if (closed) {
|
|
1039
|
+
return Promise.reject(new Error("adrkit MCP server handle is closed"));
|
|
1040
|
+
}
|
|
1041
|
+
if (startPromise)
|
|
1042
|
+
return startPromise;
|
|
1043
|
+
startPromise = (async () => {
|
|
1044
|
+
let nextServer;
|
|
1045
|
+
try {
|
|
1046
|
+
const roots = await resolveCanonicalRoots({ cwd, dir });
|
|
1047
|
+
const config = {
|
|
1048
|
+
configuredCwd: cwd,
|
|
1049
|
+
configuredDir: dir,
|
|
1050
|
+
expectedCanonicalCwd: roots.canonicalCwd,
|
|
1051
|
+
maxSourceBytes: MAX_SOURCE_BYTES
|
|
1052
|
+
};
|
|
1053
|
+
nextServer = buildRegisteredServer(config);
|
|
1054
|
+
server = nextServer;
|
|
1055
|
+
await nextServer.connect(new StdioServerTransport);
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
server = undefined;
|
|
1058
|
+
if (nextServer) {
|
|
1059
|
+
try {
|
|
1060
|
+
await nextServer.close();
|
|
1061
|
+
} catch (closeError) {
|
|
1062
|
+
startPromise = undefined;
|
|
1063
|
+
throw new AggregateError([error, closeError], "MCP server startup and cleanup failed");
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
startPromise = undefined;
|
|
1067
|
+
throw error;
|
|
1068
|
+
}
|
|
1069
|
+
})();
|
|
1070
|
+
return startPromise;
|
|
1071
|
+
}
|
|
1072
|
+
function close() {
|
|
1073
|
+
if (closePromise)
|
|
1074
|
+
return closePromise;
|
|
1075
|
+
closed = true;
|
|
1076
|
+
closePromise = (async () => {
|
|
1077
|
+
if (startPromise)
|
|
1078
|
+
await startPromise;
|
|
1079
|
+
const current = server;
|
|
1080
|
+
server = undefined;
|
|
1081
|
+
if (current)
|
|
1082
|
+
await current.close();
|
|
1083
|
+
})();
|
|
1084
|
+
return closePromise;
|
|
1085
|
+
}
|
|
1086
|
+
const handle = Object.create(null);
|
|
1087
|
+
handle.start = start;
|
|
1088
|
+
handle.close = close;
|
|
1089
|
+
return Object.freeze(handle);
|
|
1090
|
+
}
|
|
1091
|
+
export {
|
|
1092
|
+
createAdrkitMcpServer
|
|
1093
|
+
};
|