@ogygie/mcp 0.9.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/dist/server.js ADDED
@@ -0,0 +1,1304 @@
1
+ // src/main.ts
2
+ import { resolve as resolve2 } from "node:path";
3
+
4
+ // ../ogygie-core/src/repo-adapter.ts
5
+ import { readFile as readFileFromDisk, readdir, realpath, stat } from "node:fs/promises";
6
+ import { isAbsolute, join, resolve, sep } from "node:path";
7
+ import { z as z2 } from "zod";
8
+
9
+ // ../ogygie-core/src/error.schema.ts
10
+ import { z } from "zod";
11
+ var OgygieErrorSchema = z.object({
12
+ ogygie: z.literal(true).describe("Marker: lets an agent tell our errors from noise"),
13
+ code: z.string().regex(/^[A-Z][A-Z0-9_]+$/, "SCREAMING_SNAKE_CASE, stable over time"),
14
+ message: z.string().min(10).describe("Human-readable sentence (technical English)"),
15
+ file: z.string().nullable().describe("Relative path of the file involved, null when not localizable"),
16
+ cause: z.string().describe("The precise datum or condition that triggered the error"),
17
+ suggestedFix: z.string().describe("Concrete action to fix \u2014 never empty"),
18
+ details: z.record(z.string(), z.unknown()).default({}),
19
+ timestamp: z.iso.datetime()
20
+ });
21
+ function ogygieError(input) {
22
+ return OgygieErrorSchema.parse({
23
+ ogygie: true,
24
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
25
+ details: {},
26
+ ...input
27
+ });
28
+ }
29
+ var OgygieFailure = class extends Error {
30
+ error;
31
+ httpStatus;
32
+ constructor(error, httpStatus = 500) {
33
+ super(error.message);
34
+ this.name = "OgygieFailure";
35
+ this.error = error;
36
+ this.httpStatus = httpStatus;
37
+ }
38
+ };
39
+ function throwOgygieError(input, httpStatus = 500) {
40
+ throw new OgygieFailure(ogygieError(input), httpStatus);
41
+ }
42
+
43
+ // ../ogygie-core/src/repo-adapter.ts
44
+ var RepoFileEntrySchema = z2.object({
45
+ /** Path relative to the LISTED directory, "/" separated, never absolute. */
46
+ path: z2.string(),
47
+ sizeBytes: z2.number().int().nonnegative()
48
+ });
49
+ var NEVER_LISTED = /* @__PURE__ */ new Set(["node_modules"]);
50
+ function isSkippedDirectory(name) {
51
+ return name.startsWith(".") || NEVER_LISTED.has(name);
52
+ }
53
+ function refusePathOutsideRepo(relativePath, cause) {
54
+ return throwOgygieError(
55
+ {
56
+ code: "PATH_OUTSIDE_REPO",
57
+ message: "The requested path resolves outside the repo root.",
58
+ file: relativePath,
59
+ cause,
60
+ suggestedFix: "Address a path INSIDE the repo, written relative to its root and without `..`."
61
+ },
62
+ 400
63
+ );
64
+ }
65
+ function resolveInsideRoot(rootDir, relativePath) {
66
+ if (isAbsolute(relativePath)) {
67
+ refusePathOutsideRepo(relativePath, `"${relativePath}" is an absolute path`);
68
+ }
69
+ const root = resolve(rootDir);
70
+ const absolute = resolve(root, relativePath);
71
+ if (absolute !== root && !absolute.startsWith(root + sep)) {
72
+ refusePathOutsideRepo(relativePath, `"${relativePath}" resolves to "${absolute}", outside "${root}"`);
73
+ }
74
+ return absolute;
75
+ }
76
+ async function assertRealPathInsideRoot(rootDir, absolute, relativePath) {
77
+ let realRoot;
78
+ let realTarget;
79
+ try {
80
+ realRoot = await realpath(rootDir);
81
+ realTarget = await realpath(absolute);
82
+ } catch {
83
+ return;
84
+ }
85
+ if (realTarget !== realRoot && !realTarget.startsWith(realRoot + sep)) {
86
+ refusePathOutsideRepo(
87
+ relativePath,
88
+ `"${relativePath}" is a symbolic link leading to "${realTarget}", outside "${realRoot}"`
89
+ );
90
+ }
91
+ }
92
+ async function listFilesRecursively(absoluteDir, prefix) {
93
+ let entries;
94
+ try {
95
+ entries = await readdir(absoluteDir, { withFileTypes: true });
96
+ } catch {
97
+ return [];
98
+ }
99
+ const files = [];
100
+ for (const entry of entries) {
101
+ const relativePath = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
102
+ if (entry.isDirectory()) {
103
+ if (isSkippedDirectory(entry.name)) continue;
104
+ files.push(...await listFilesRecursively(join(absoluteDir, entry.name), relativePath));
105
+ } else if (entry.isFile()) {
106
+ const stats = await stat(join(absoluteDir, entry.name));
107
+ files.push({ path: relativePath, sizeBytes: stats.size });
108
+ }
109
+ }
110
+ return files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
111
+ }
112
+ async function resolveGitDirs(rootDir) {
113
+ const dotGit = join(rootDir, ".git");
114
+ let gitDir;
115
+ try {
116
+ const stats = await stat(dotGit);
117
+ if (stats.isDirectory()) {
118
+ gitDir = dotGit;
119
+ } else {
120
+ const pointer = (await readFileFromDisk(dotGit, "utf8")).trim();
121
+ const target = pointer.startsWith("gitdir:") ? pointer.slice("gitdir:".length).trim() : "";
122
+ if (target === "") return null;
123
+ gitDir = isAbsolute(target) ? target : resolve(rootDir, target);
124
+ }
125
+ } catch {
126
+ return null;
127
+ }
128
+ try {
129
+ const commonPointer = (await readFileFromDisk(join(gitDir, "commondir"), "utf8")).trim();
130
+ return { gitDir, commonDir: isAbsolute(commonPointer) ? commonPointer : resolve(gitDir, commonPointer) };
131
+ } catch {
132
+ return { gitDir, commonDir: gitDir };
133
+ }
134
+ }
135
+ async function readRefSha(dirs, refName) {
136
+ for (const base of [dirs.gitDir, dirs.commonDir]) {
137
+ try {
138
+ const sha = (await readFileFromDisk(join(base, refName), "utf8")).trim();
139
+ if (sha !== "") return sha;
140
+ } catch {
141
+ }
142
+ }
143
+ try {
144
+ const packed = await readFileFromDisk(join(dirs.commonDir, "packed-refs"), "utf8");
145
+ for (const line of packed.split("\n")) {
146
+ if (line.startsWith("#") || line.startsWith("^")) continue;
147
+ const [sha, name] = line.trim().split(" ");
148
+ if (name === refName && sha !== void 0 && sha !== "") return sha;
149
+ }
150
+ } catch {
151
+ return null;
152
+ }
153
+ return null;
154
+ }
155
+ function createFilesystemRepoAdapter(rootDir) {
156
+ return {
157
+ async readFile(relativePath) {
158
+ const absolute = resolveInsideRoot(rootDir, relativePath);
159
+ await assertRealPathInsideRoot(rootDir, absolute, relativePath);
160
+ try {
161
+ return await readFileFromDisk(absolute, "utf8");
162
+ } catch (thrown) {
163
+ return throwOgygieError({
164
+ code: "REPO_FILE_NOT_FOUND",
165
+ message: "The requested file cannot be read from the repo.",
166
+ file: relativePath,
167
+ cause: `reading "${absolute}" failed: ${thrown instanceof Error ? thrown.message : String(thrown)}`,
168
+ suggestedFix: "Check the path against what the map declares \u2014 the manifest is the only chart of a repo."
169
+ });
170
+ }
171
+ },
172
+ async listDirectory(relativePath) {
173
+ const absolute = resolveInsideRoot(rootDir, relativePath);
174
+ await assertRealPathInsideRoot(rootDir, absolute, relativePath);
175
+ return listFilesRecursively(absolute, "");
176
+ },
177
+ async exists(relativePath) {
178
+ const absolute = resolveInsideRoot(rootDir, relativePath);
179
+ await assertRealPathInsideRoot(rootDir, absolute, relativePath);
180
+ try {
181
+ await stat(absolute);
182
+ return true;
183
+ } catch {
184
+ return false;
185
+ }
186
+ },
187
+ async currentRef() {
188
+ const dirs = await resolveGitDirs(rootDir);
189
+ if (dirs === null) return null;
190
+ let head;
191
+ try {
192
+ head = (await readFileFromDisk(join(dirs.gitDir, "HEAD"), "utf8")).trim();
193
+ } catch {
194
+ return null;
195
+ }
196
+ if (!head.startsWith("ref:")) return head === "" ? null : head;
197
+ return readRefSha(dirs, head.slice("ref:".length).trim());
198
+ }
199
+ };
200
+ }
201
+
202
+ // ../ogygie-core/src/manifest.schema.ts
203
+ import { z as z3 } from "zod";
204
+ var FeatureNameSchema = z3.string().regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, "kebab-case required");
205
+ var PackageDependencySchema = z3.string().regex(/^package:[a-z][a-z0-9]*(-[a-z0-9]+)*$/, "expected package:<kebab-case-name>");
206
+ var HttpRouteSchema = z3.object({
207
+ method: z3.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
208
+ path: z3.string().startsWith("/api/"),
209
+ description: z3.string().min(10),
210
+ schemaInput: z3.string().describe("Exported name of the input Zod schema, e.g. CreateOrderInputSchema"),
211
+ schemaOutput: z3.string().describe("Exported name of the output Zod schema"),
212
+ invalidates: z3.array(z3.string()).optional().describe(
213
+ "Cache roots this mutation stales BEYOND its own path root (cross-feature dependency, read by the manifest-derived client \u2014 never guessed in a screen). Each value must be an existing feature name or route root"
214
+ )
215
+ });
216
+ var SharedTableKindSchema = z3.enum(["reference", "ownerless"]);
217
+ var DbTableDeclarationSchema = z3.union([
218
+ z3.string(),
219
+ z3.object({
220
+ name: z3.string(),
221
+ shared: z3.literal(true),
222
+ sharedKind: SharedTableKindSchema.optional()
223
+ })
224
+ ]);
225
+ var FeatureManifestSchema = z3.object({
226
+ name: FeatureNameSchema,
227
+ intent: z3.string().min(20).max(300).describe("The feature's 'why' in 1-3 sentences"),
228
+ app: z3.string().optional().describe("Name of the owning app (apps[].name); defaults to the api app"),
229
+ folder: z3.string().describe("Path relative to the app root, e.g. src/features/orders"),
230
+ spec: z3.string().nullable().describe("Path to the spec, e.g. specs/orders.spec.md, null for technical features"),
231
+ routes: z3.array(HttpRouteSchema),
232
+ dbTables: z3.array(DbTableDeclarationSchema).describe(
233
+ "Drizzle tables owned by this feature (one table = one owner). String = tenant-scoped; { name, shared: true } = shared referential"
234
+ ),
235
+ dependsOn: z3.array(z3.union([FeatureNameSchema, PackageDependencySchema])).describe(
236
+ "Features imported through their index.ts, and product packages as package:<name> \u2014 nothing else"
237
+ ),
238
+ status: z3.enum(["skeleton", "in-progress", "delivered"])
239
+ });
240
+ var OgygieVersionSchema = z3.string().regex(/^\d+\.\d+\.\d+$/, "expected a plain x.y.z version");
241
+ var FrameworkManifestSchema = z3.object({
242
+ packages: z3.array(
243
+ z3.object({
244
+ name: z3.string().describe("Published npm name, e.g. @ogygie/core"),
245
+ path: z3.string().describe("Folder relative to the repo root"),
246
+ intent: z3.string().min(20),
247
+ /**
248
+ * The spec describing this package, when one does. Named EXPLICITLY
249
+ * because a published npm name (`@ogygie/mcp`) is not a spec id
250
+ * (`ogygie-mcp`) — the other two natures of entry match by name, this
251
+ * one cannot. Several packages may point at the same spec: a spec of
252
+ * the framework often spans the core and the CLI.
253
+ */
254
+ spec: z3.string().nullable(),
255
+ /**
256
+ * How far along, in the SAME words as the other natures. Required, and
257
+ * deliberately without default: a lifecycle gate that guesses a status
258
+ * confirms specs nobody delivered.
259
+ */
260
+ status: z3.enum(["skeleton", "in-progress", "delivered"])
261
+ })
262
+ ),
263
+ templates: z3.object({
264
+ project: z3.string().describe("Folder copied by `ogygie init`"),
265
+ feature: z3.string().describe("Reserved for `ogygie generate feature` (not wired yet)")
266
+ }),
267
+ governedFiles: z3.string().describe("Module declaring the exhaustive list of files `upgrade` may overwrite (D3)")
268
+ });
269
+ var VocabularyEntrySchema = z3.object({
270
+ canonical: z3.string().regex(/^[a-z][a-zA-Z0-9]*$/, "camelCase English term required").describe("The one machine term for this concept, e.g. gadget"),
271
+ forbiddenAliases: z3.array(
272
+ z3.string().min(2).regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, "lowercase root (kebab-case allowed) required")
273
+ ).min(1).describe("Roots the code may never carry for this concept, e.g. widget"),
274
+ reference: z3.string().optional().describe("Anchor into AGENTS.product.md where the concept is explained")
275
+ });
276
+ var ProductVocabularySchema = z3.object({
277
+ entries: z3.array(VocabularyEntrySchema)
278
+ });
279
+ var PackageManifestSchema = z3.object({
280
+ name: FeatureNameSchema,
281
+ path: z3.string().describe("Folder relative to the repo root, e.g. packages/solver"),
282
+ intent: z3.string().min(20).max(300).describe("The package's 'why' in 1-3 sentences"),
283
+ spec: z3.string().nullable(),
284
+ dependsOn: z3.array(PackageDependencySchema).describe("Other PRODUCT packages, as package:<name> \u2014 never a feature: a package that depended on one would live under an app, and would be a feature"),
285
+ status: z3.enum(["skeleton", "in-progress", "delivered"])
286
+ });
287
+ var TenancyManifestSchema = z3.object({
288
+ column: z3.string().min(1).describe("SQL name of the tenant column, e.g. workspace_id \u2014 the one AGENTS.product.md announces"),
289
+ rawDbAllowed: z3.array(FeatureNameSchema).default([]).describe(
290
+ "Features allowed to reach the raw database: the tenancy socle, which BUILDS the contexts and therefore predates them. Every other feature goes through tenantDb"
291
+ )
292
+ });
293
+ var OgygieManifestSchema = z3.object({
294
+ version: z3.literal(1),
295
+ product: z3.object({
296
+ name: z3.string(),
297
+ description: z3.string().min(20)
298
+ }),
299
+ ogygieVersion: OgygieVersionSchema.optional(),
300
+ framework: FrameworkManifestSchema.optional(),
301
+ apps: z3.array(
302
+ z3.object({
303
+ name: z3.string(),
304
+ path: z3.string(),
305
+ type: z3.enum(["api", "front", "gateway"]),
306
+ entryPoint: z3.string(),
307
+ /**
308
+ * Code the product HOSTS without maintaining it — imported as-is from
309
+ * elsewhere, the framework's own dev tooling being the typical case.
310
+ * A vendored app is skipped by the vocabulary gate: a product's business
311
+ * vocabulary has no claim over code it will never rename.
312
+ *
313
+ * A literal `true`, like `shared` on a table: an app is either vendored
314
+ * (and says so) or the product's own (and stays silent) — no third
315
+ * state. The derogation is declared here, machine-readably, and the gate
316
+ * ANNOUNCES it on every run — one that stops being visible is one that
317
+ * gets forgotten.
318
+ */
319
+ vendored: z3.literal(true).optional()
320
+ })
321
+ ),
322
+ features: z3.array(FeatureManifestSchema),
323
+ tenancy: TenancyManifestSchema.optional().describe(
324
+ "Product values the framework gates need \u2014 absent, the tenancy checks do not run"
325
+ ),
326
+ packages: z3.array(PackageManifestSchema).optional().describe("Product code living outside every app \u2014 libraries, engines. Absent = the project has none"),
327
+ vocabulary: ProductVocabularySchema.optional().describe(
328
+ "The product's closed vocabulary \u2014 declared here, enforced by `ogygie verify-vocabulary`. Absent = gate silent"
329
+ ),
330
+ conventions: z3.object({
331
+ agentsMd: z3.literal("AGENTS.md"),
332
+ failuresMd: z3.literal("FAILURES.md"),
333
+ specsFolder: z3.literal("specs/")
334
+ })
335
+ }).superRefine((manifest, context) => {
336
+ const featureNames = new Set(manifest.features.map((feature) => feature.name));
337
+ const packageNames = /* @__PURE__ */ new Set();
338
+ (manifest.packages ?? []).forEach((packageEntry, index) => {
339
+ if (featureNames.has(packageEntry.name)) {
340
+ context.addIssue({
341
+ code: "custom",
342
+ path: ["packages", index, "name"],
343
+ message: `"${packageEntry.name}" is already the name of a feature \u2014 one name designates one thing`
344
+ });
345
+ }
346
+ if (packageNames.has(packageEntry.name)) {
347
+ context.addIssue({
348
+ code: "custom",
349
+ path: ["packages", index, "name"],
350
+ message: `package "${packageEntry.name}" is declared twice`
351
+ });
352
+ }
353
+ packageNames.add(packageEntry.name);
354
+ });
355
+ const specIdOf3 = (specPath) => (specPath.split("/").pop() ?? specPath).replace(/\.spec\.md$/, "");
356
+ const ownedSpecIds = /* @__PURE__ */ new Set([
357
+ ...manifest.features.map((feature) => feature.name),
358
+ ...packageNames
359
+ ]);
360
+ (manifest.framework?.packages ?? []).forEach((packageEntry, index) => {
361
+ if (packageEntry.spec === null) return;
362
+ const specId = specIdOf3(packageEntry.spec);
363
+ if (ownedSpecIds.has(specId)) {
364
+ context.addIssue({
365
+ code: "custom",
366
+ path: ["framework", "packages", index, "spec"],
367
+ message: `spec "${specId}" is already owned by a feature or a product package \u2014 one spec, one owner`
368
+ });
369
+ }
370
+ });
371
+ const checkPackageDependencies = (dependsOn, path) => {
372
+ dependsOn.forEach((dependency, dependencyIndex) => {
373
+ if (!dependency.startsWith("package:")) return;
374
+ const target = dependency.slice("package:".length);
375
+ if (!packageNames.has(target)) {
376
+ context.addIssue({
377
+ code: "custom",
378
+ path: [...path, dependencyIndex],
379
+ message: `dependency "${dependency}" matches no declared package`
380
+ });
381
+ }
382
+ });
383
+ };
384
+ manifest.features.forEach((feature, index) => {
385
+ checkPackageDependencies(feature.dependsOn, ["features", index, "dependsOn"]);
386
+ });
387
+ (manifest.packages ?? []).forEach((packageEntry, index) => {
388
+ checkPackageDependencies(packageEntry.dependsOn, ["packages", index, "dependsOn"]);
389
+ });
390
+ (manifest.tenancy?.rawDbAllowed ?? []).forEach((featureName, index) => {
391
+ if (!featureNames.has(featureName)) {
392
+ context.addIssue({
393
+ code: "custom",
394
+ path: ["tenancy", "rawDbAllowed", index],
395
+ message: `"${featureName}" matches no declared feature`
396
+ });
397
+ }
398
+ });
399
+ const canonicalOwners = /* @__PURE__ */ new Map();
400
+ const aliasOwners = /* @__PURE__ */ new Map();
401
+ (manifest.vocabulary?.entries ?? []).forEach((entry, index) => {
402
+ const canonicalRoot = entry.canonical.toLowerCase();
403
+ const firstOwner = canonicalOwners.get(canonicalRoot);
404
+ if (firstOwner !== void 0) {
405
+ context.addIssue({
406
+ code: "custom",
407
+ path: ["vocabulary", "entries", index, "canonical"],
408
+ message: `canonical "${entry.canonical}" is already declared by entry ${firstOwner}`
409
+ });
410
+ } else {
411
+ canonicalOwners.set(canonicalRoot, index);
412
+ }
413
+ });
414
+ (manifest.vocabulary?.entries ?? []).forEach((entry, index) => {
415
+ entry.forbiddenAliases.forEach((alias, aliasIndex) => {
416
+ const path = ["vocabulary", "entries", index, "forbiddenAliases", aliasIndex];
417
+ const canonicalOwner = canonicalOwners.get(alias);
418
+ if (canonicalOwner !== void 0) {
419
+ context.addIssue({
420
+ code: "custom",
421
+ path,
422
+ message: `alias "${alias}" is the canonical term of entry ${canonicalOwner} \u2014 a term cannot be both`
423
+ });
424
+ return;
425
+ }
426
+ const firstOwner = aliasOwners.get(alias);
427
+ if (firstOwner !== void 0) {
428
+ context.addIssue({
429
+ code: "custom",
430
+ path,
431
+ message: `alias "${alias}" is already forbidden by entry ${firstOwner} \u2014 one alias belongs to one concept`
432
+ });
433
+ return;
434
+ }
435
+ aliasOwners.set(alias, index);
436
+ });
437
+ });
438
+ const appsByName = new Map(manifest.apps.map((app) => [app.name, app]));
439
+ const knownInvalidationTargets = new Set(
440
+ manifest.features.map((feature) => feature.name)
441
+ );
442
+ for (const feature of manifest.features) {
443
+ for (const route of feature.routes) {
444
+ const root = route.path.split("/")[2];
445
+ if (root !== void 0) knownInvalidationTargets.add(root);
446
+ }
447
+ }
448
+ manifest.features.forEach((feature, index) => {
449
+ feature.routes.forEach((route, routeIndex) => {
450
+ for (const target of route.invalidates ?? []) {
451
+ if (!knownInvalidationTargets.has(target)) {
452
+ context.addIssue({
453
+ code: "custom",
454
+ path: ["features", index, "routes", routeIndex, "invalidates"],
455
+ message: `invalidates target "${target}" matches no feature name and no route root`
456
+ });
457
+ }
458
+ }
459
+ });
460
+ });
461
+ manifest.features.forEach((feature, index) => {
462
+ if (feature.app === void 0) return;
463
+ const owningApp = appsByName.get(feature.app);
464
+ if (owningApp === void 0) {
465
+ context.addIssue({
466
+ code: "custom",
467
+ path: ["features", index, "app"],
468
+ message: `app "${feature.app}" is not declared in apps[]`
469
+ });
470
+ return;
471
+ }
472
+ if (owningApp.type !== "api" && (feature.routes.length > 0 || feature.dbTables.length > 0)) {
473
+ context.addIssue({
474
+ code: "custom",
475
+ path: ["features", index],
476
+ message: "a non-api feature owns no HTTP routes and no DB tables \u2014 declare them on the api feature"
477
+ });
478
+ }
479
+ });
480
+ });
481
+
482
+ // src/schemas.ts
483
+ import { z as z5 } from "zod";
484
+
485
+ // ../ogygie-core/src/spec.schema.ts
486
+ import { z as z4 } from "zod";
487
+ var DeferredBehaviorSchema = z4.string().regex(
488
+ /^.+ blockedBy:[a-z][a-z0-9]*(-[a-z0-9]+)*$/,
489
+ 'Expected "<behavior> blockedBy:<feature-name>" (feature name in kebab-case)'
490
+ );
491
+ var SpecFrontmatterSchema = z4.object({
492
+ id: FeatureNameSchema.describe("Identical to the feature name in the manifest"),
493
+ status: z4.enum(["draft", "validated", "implemented"]),
494
+ depends_on: z4.array(FeatureNameSchema),
495
+ priority: z4.enum(["P0", "P1", "P2"]),
496
+ refs: z4.array(z4.string()).default([]).describe("References to the domain model, e.g. 'modele-gally \xA74.2'"),
497
+ deferred: z4.array(DeferredBehaviorSchema).default([]).describe("Behaviors deferred to a later feature (DECISIONS.md D13)")
498
+ });
499
+ function splitInlineArrayElements(inner) {
500
+ const elements = [];
501
+ let current = "";
502
+ let openQuote = null;
503
+ for (const character of inner) {
504
+ if (openQuote === null && (character === '"' || character === "'")) {
505
+ openQuote = character;
506
+ current += character;
507
+ } else if (character === openQuote) {
508
+ openQuote = null;
509
+ current += character;
510
+ } else if (openQuote === null && character === ",") {
511
+ elements.push(current);
512
+ current = "";
513
+ } else {
514
+ current += character;
515
+ }
516
+ }
517
+ elements.push(current);
518
+ return { elements, unbalancedQuote: openQuote !== null };
519
+ }
520
+ function unquote(raw, location, problems) {
521
+ const trimmed = raw.trim();
522
+ const first = trimmed[0];
523
+ if (first === '"' || first === "'") {
524
+ if (trimmed.length < 2 || !trimmed.endsWith(first)) {
525
+ problems.push(`${location}: unbalanced quotes in ${JSON.stringify(trimmed)}`);
526
+ return null;
527
+ }
528
+ return trimmed.slice(1, -1);
529
+ }
530
+ return trimmed;
531
+ }
532
+ function parseSpecFrontmatter(text) {
533
+ const value = {};
534
+ const problems = [];
535
+ const lines = text.split("\n");
536
+ for (let index = 0; index < lines.length; index += 1) {
537
+ const trimmed = (lines[index] ?? "").trim();
538
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
539
+ const colonIndex = trimmed.indexOf(":");
540
+ if (colonIndex === -1 || trimmed.startsWith('"') || trimmed.startsWith("'")) {
541
+ problems.push(
542
+ `unparseable line ${JSON.stringify(trimmed)}: expected \`key: value\` \u2014 a spilled array element outside any array? (scalars and arrays of scalars only, D13)`
543
+ );
544
+ continue;
545
+ }
546
+ const key = trimmed.slice(0, colonIndex).trim();
547
+ let rawValue = trimmed.slice(colonIndex + 1).trim();
548
+ if (rawValue.startsWith("[")) {
549
+ let closed = rawValue.endsWith("]");
550
+ while (!closed && index + 1 < lines.length) {
551
+ index += 1;
552
+ rawValue = `${rawValue} ${(lines[index] ?? "").trim()}`;
553
+ closed = rawValue.endsWith("]");
554
+ }
555
+ if (!closed) {
556
+ problems.push(
557
+ `key "${key}": array opened with "[" and never closed with "]" before the end of the frontmatter \u2014 a truncated array would be silently read as empty, hence refused (D13)`
558
+ );
559
+ continue;
560
+ }
561
+ const { elements, unbalancedQuote } = splitInlineArrayElements(
562
+ rawValue.slice(1, -1)
563
+ );
564
+ if (unbalancedQuote) {
565
+ problems.push(`key "${key}": unbalanced quotes inside the array`);
566
+ continue;
567
+ }
568
+ const parsedElements = [];
569
+ let broken = false;
570
+ for (const element of elements) {
571
+ if (element.trim() === "") continue;
572
+ const unquoted = unquote(element, `key "${key}"`, problems);
573
+ if (unquoted === null) broken = true;
574
+ else parsedElements.push(unquoted);
575
+ }
576
+ if (!broken) value[key] = parsedElements;
577
+ } else {
578
+ const unquoted = unquote(rawValue, `key "${key}"`, problems);
579
+ if (unquoted !== null) value[key] = unquoted;
580
+ }
581
+ }
582
+ return { value, problems };
583
+ }
584
+
585
+ // src/schemas.ts
586
+ var RepoIdSchema = z5.string().regex(/^[a-z0-9-]+$/);
587
+ var FeatureNameSchema2 = z5.string().regex(/^[a-z0-9-]+$/);
588
+ var SpecIdSchema = FeatureNameSchema2;
589
+ var DecisionIdSchema = z5.string().regex(/^D\d+$/);
590
+ var SpecStatusSchema = z5.enum(["draft", "validated", "implemented"]);
591
+ var GovernanceFileSchema = z5.enum([
592
+ "AGENTS.md",
593
+ "AGENTS.product.md",
594
+ "FAILURES.md"
595
+ ]);
596
+ var DECISION_FILES = ["DECISIONS.md", "DECISIONS.product.md"];
597
+ var RepoRegistrySchema = z5.object({
598
+ repos: z5.array(
599
+ z5.object({
600
+ id: RepoIdSchema,
601
+ rootPath: z5.string().min(1)
602
+ })
603
+ ).min(1)
604
+ });
605
+ var ProvenanceSchema = z5.object({
606
+ repo: RepoIdSchema,
607
+ path: z5.string(),
608
+ /** HEAD SHA — null when the served root is not a git clone. */
609
+ ref: z5.string().nullable()
610
+ });
611
+ var ReadManifestInput = { repo: RepoIdSchema };
612
+ var ListSpecsInput = { repo: RepoIdSchema, status: SpecStatusSchema.optional() };
613
+ var ReadSpecInput = { repo: RepoIdSchema, id: SpecIdSchema };
614
+ var ReadDecisionsInput = { repo: RepoIdSchema, decision: DecisionIdSchema.optional() };
615
+ var ReadGovernanceInput = { repo: RepoIdSchema, file: GovernanceFileSchema };
616
+ var ReadFeatureInput = {
617
+ repo: RepoIdSchema,
618
+ feature: FeatureNameSchema2,
619
+ file: z5.string().optional()
620
+ };
621
+ var RepoInfoSchema = z5.object({
622
+ id: RepoIdSchema,
623
+ /** null when the manifest declares none — the framework's own repo does not. */
624
+ ogygieVersion: z5.string().nullable(),
625
+ ref: z5.string().nullable()
626
+ });
627
+ var SpecSummarySchema = z5.object({
628
+ id: SpecIdSchema,
629
+ status: SpecStatusSchema,
630
+ priority: z5.enum(["P0", "P1", "P2"]),
631
+ dependsOn: z5.array(SpecIdSchema),
632
+ deferred: z5.array(z5.string())
633
+ });
634
+ var SpecListingSchema = z5.object({
635
+ specs: z5.array(SpecSummarySchema),
636
+ invalid: z5.array(z5.object({ id: SpecIdSchema, problems: z5.array(z5.string()) }))
637
+ });
638
+ var SpecDocumentSchema = z5.object({
639
+ frontmatter: SpecFrontmatterSchema.nullable(),
640
+ frontmatterProblems: z5.array(z5.string()),
641
+ body: z5.string(),
642
+ provenance: ProvenanceSchema
643
+ });
644
+ var TextDocumentSchema = z5.object({
645
+ content: z5.string(),
646
+ provenance: ProvenanceSchema
647
+ });
648
+ var FeatureListingSchema = z5.object({
649
+ feature: FeatureNameSchema2,
650
+ files: z5.array(z5.object({ path: z5.string(), sizeBytes: z5.number().int().nonnegative() })),
651
+ provenance: ProvenanceSchema
652
+ });
653
+ var McpErrorCodeSchema = z5.enum([
654
+ "REGISTRY_INVALID",
655
+ "REPO_NOT_OGYGIE",
656
+ "REPO_VERSION_UNSUPPORTED",
657
+ "REPO_UNKNOWN",
658
+ "MANIFEST_INVALID",
659
+ "SPEC_NOT_FOUND",
660
+ "DECISION_NOT_FOUND",
661
+ "FEATURE_NOT_FOUND",
662
+ "FILE_TOO_LARGE",
663
+ "FILE_NOT_TEXT",
664
+ "PATH_OUTSIDE_REPO",
665
+ "UNAUTHORIZED",
666
+ "TOKEN_MISSING"
667
+ ]);
668
+ var MAX_FILE_BYTES = 2e5;
669
+ var MIN_TOKEN_LENGTH = 32;
670
+
671
+ // src/registry.ts
672
+ var MANIFEST_PATH = "ogygie.manifest.json";
673
+ function parseRegistry(raw) {
674
+ const parsed = RepoRegistrySchema.safeParse(raw);
675
+ if (!parsed.success) {
676
+ return throwOgygieError({
677
+ code: "REGISTRY_INVALID",
678
+ message: "The MCP repo registry does not match RepoRegistrySchema.",
679
+ file: null,
680
+ cause: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" ; "),
681
+ suggestedFix: 'Write { "repos": [{ "id": "<kebab-case>", "rootPath": "<path>" }] } with at least one entry.'
682
+ });
683
+ }
684
+ return parsed.data;
685
+ }
686
+ function isNewerThan(candidate, reference) {
687
+ const toParts = (version) => version.split(".").map((part) => Number.parseInt(part, 10) || 0);
688
+ const left = toParts(candidate);
689
+ const right = toParts(reference);
690
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
691
+ const a = left[index] ?? 0;
692
+ const b = right[index] ?? 0;
693
+ if (a !== b) return a > b;
694
+ }
695
+ return false;
696
+ }
697
+ async function readManifest(repo) {
698
+ const raw = await repo.adapter.readFile(MANIFEST_PATH);
699
+ let parsedJson;
700
+ try {
701
+ parsedJson = JSON.parse(raw);
702
+ } catch (thrown) {
703
+ return throwOgygieError({
704
+ code: "MANIFEST_INVALID",
705
+ message: `The manifest of repo "${repo.id}" is not valid JSON.`,
706
+ file: MANIFEST_PATH,
707
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
708
+ suggestedFix: "Fix the JSON syntax of ogygie.manifest.json in that repo."
709
+ });
710
+ }
711
+ const parsed = OgygieManifestSchema.safeParse(parsedJson);
712
+ if (!parsed.success) {
713
+ return throwOgygieError({
714
+ code: "MANIFEST_INVALID",
715
+ message: `The manifest of repo "${repo.id}" does not match OgygieManifestSchema.`,
716
+ file: MANIFEST_PATH,
717
+ cause: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" ; "),
718
+ suggestedFix: "Run `pnpm ogygie:verify-manifest` in that repo \u2014 a client reading an invalid map would decide on a fiction."
719
+ });
720
+ }
721
+ return parsed.data;
722
+ }
723
+ async function openRepos(registry, deps) {
724
+ const opened = [];
725
+ for (const declared of registry.repos) {
726
+ const adapter = deps.openRepo(declared.rootPath);
727
+ if (!await adapter.exists(MANIFEST_PATH)) {
728
+ throwOgygieError({
729
+ code: "REPO_NOT_OGYGIE",
730
+ message: `Repo "${declared.id}" has no ${MANIFEST_PATH} at its root.`,
731
+ file: declared.rootPath,
732
+ cause: `${declared.rootPath}/${MANIFEST_PATH} does not exist`,
733
+ suggestedFix: "Point rootPath at the ROOT of an Ogygie repo, or remove the entry from the registry."
734
+ });
735
+ }
736
+ const repo = {
737
+ id: declared.id,
738
+ rootPath: declared.rootPath,
739
+ adapter,
740
+ ogygieVersion: null
741
+ };
742
+ const manifest = await readManifest(repo);
743
+ repo.ogygieVersion = manifest.ogygieVersion ?? null;
744
+ if (repo.ogygieVersion !== null && isNewerThan(repo.ogygieVersion, deps.serverVersion)) {
745
+ throwOgygieError({
746
+ code: "REPO_VERSION_UNSUPPORTED",
747
+ message: `Repo "${declared.id}" pins Ogygie ${repo.ogygieVersion}, newer than this server (${deps.serverVersion}).`,
748
+ file: MANIFEST_PATH,
749
+ cause: `ogygieVersion=${repo.ogygieVersion} > server=${deps.serverVersion}`,
750
+ suggestedFix: `Update the server to a version \u2265 ${repo.ogygieVersion}: pnpm update @ogygie/mcp@${repo.ogygieVersion}`
751
+ });
752
+ }
753
+ opened.push(repo);
754
+ }
755
+ return opened;
756
+ }
757
+ function repoOf(repos, repoId) {
758
+ const found = repos.find((candidate) => candidate.id === repoId);
759
+ if (found === void 0) {
760
+ return throwOgygieError({
761
+ code: "REPO_UNKNOWN",
762
+ message: `No repo "${repoId}" is registered on this server.`,
763
+ file: null,
764
+ cause: `known repos: ${repos.map((repo) => repo.id).join(", ") || "<none>"}`,
765
+ suggestedFix: "Call ogygie_list_repos to see what this server serves."
766
+ });
767
+ }
768
+ return found;
769
+ }
770
+
771
+ // src/server.ts
772
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
773
+
774
+ // ../ogygie-core/src/decision-refs.ts
775
+ var DECISION_HEADING = /^##\s+D(\d+)\b/;
776
+ function parseKnownDecisionIds(decisionsMarkdown) {
777
+ const ids = [];
778
+ for (const line of decisionsMarkdown.split("\n")) {
779
+ const heading = DECISION_HEADING.exec(line);
780
+ if (heading !== null) ids.push(`D${heading[1]}`);
781
+ }
782
+ return ids;
783
+ }
784
+ function extractDecisionSection(decisionsMarkdown, decisionId) {
785
+ const lines = decisionsMarkdown.split("\n");
786
+ const trimTrailingBlanks = (section) => {
787
+ while (section.length > 0 && (section[section.length - 1] ?? "").trim() === "") section.pop();
788
+ return section.join("\n");
789
+ };
790
+ let start = -1;
791
+ for (let index = 0; index < lines.length; index += 1) {
792
+ const heading = DECISION_HEADING.exec(lines[index] ?? "");
793
+ if (heading === null) continue;
794
+ if (start === -1) {
795
+ if (`D${heading[1]}` === decisionId) start = index;
796
+ continue;
797
+ }
798
+ return trimTrailingBlanks(lines.slice(start, index));
799
+ }
800
+ return start === -1 ? null : trimTrailingBlanks(lines.slice(start));
801
+ }
802
+
803
+ // ../ogygie-core/src/manifest-owners.ts
804
+ var ADVANCEMENT = ["skeleton", "in-progress", "delivered"];
805
+ function specIdOf(specPath) {
806
+ return (specPath.split("/").pop() ?? specPath).replace(/\.spec\.md$/, "");
807
+ }
808
+ function findSpecOwner(manifest, specId) {
809
+ const feature = manifest.features.find((candidate) => candidate.name === specId);
810
+ if (feature !== void 0) {
811
+ const owningApp = feature.app === void 0 ? manifest.apps.find((app) => app.type === "api") : manifest.apps.find((app) => app.name === feature.app);
812
+ return {
813
+ kind: "feature",
814
+ name: feature.name,
815
+ status: feature.status,
816
+ folder: owningApp === void 0 ? null : `${owningApp.path}/${feature.folder}`
817
+ };
818
+ }
819
+ const productPackage = (manifest.packages ?? []).find(
820
+ (candidate) => candidate.name === specId
821
+ );
822
+ if (productPackage !== void 0) {
823
+ return {
824
+ kind: "package",
825
+ name: productPackage.name,
826
+ status: productPackage.status,
827
+ folder: productPackage.path
828
+ };
829
+ }
830
+ const frameworkPackages = (manifest.framework?.packages ?? []).filter(
831
+ (candidate) => candidate.spec !== null && specIdOf(candidate.spec) === specId
832
+ );
833
+ const leastAdvanced = frameworkPackages.slice().sort((a, b) => ADVANCEMENT.indexOf(a.status) - ADVANCEMENT.indexOf(b.status))[0];
834
+ if (leastAdvanced !== void 0) {
835
+ return {
836
+ kind: "framework-package",
837
+ name: leastAdvanced.name,
838
+ status: leastAdvanced.status,
839
+ folder: leastAdvanced.path
840
+ };
841
+ }
842
+ return void 0;
843
+ }
844
+
845
+ // src/tools.ts
846
+ var SPECS_DIR = "specs";
847
+ async function provenanceOf(repo, path) {
848
+ return { repo: repo.id, path, ref: await repo.adapter.currentRef() };
849
+ }
850
+ async function readDocument(repo, path) {
851
+ const slash = path.lastIndexOf("/");
852
+ const parent = slash === -1 ? "" : path.slice(0, slash);
853
+ const name = slash === -1 ? path : path.slice(slash + 1);
854
+ const entry = (await repo.adapter.listDirectory(parent)).find(
855
+ (candidate) => candidate.path === name
856
+ );
857
+ if (entry !== void 0 && entry.sizeBytes > MAX_FILE_BYTES) {
858
+ throwOgygieError({
859
+ code: "FILE_TOO_LARGE",
860
+ message: `"${path}" is ${entry.sizeBytes} bytes, over the ${MAX_FILE_BYTES} the server serves.`,
861
+ file: path,
862
+ cause: `sizeBytes=${entry.sizeBytes} > ${MAX_FILE_BYTES}`,
863
+ suggestedFix: "Ask for a more precise file \u2014 a feature's schemas.ts rather than its whole folder."
864
+ });
865
+ }
866
+ const content = await repo.adapter.readFile(path);
867
+ if (content.includes("\0") || content.includes("\uFFFD")) {
868
+ throwOgygieError({
869
+ code: "FILE_NOT_TEXT",
870
+ message: `"${path}" is not UTF-8 text.`,
871
+ file: path,
872
+ cause: "the decoded content carries NUL or replacement characters",
873
+ suggestedFix: "Only text files are served \u2014 read the source next to the binary instead."
874
+ });
875
+ }
876
+ return content;
877
+ }
878
+ async function listRepos(repos) {
879
+ return {
880
+ repos: await Promise.all(
881
+ repos.map(async (repo) => ({
882
+ id: repo.id,
883
+ ogygieVersion: repo.ogygieVersion,
884
+ ref: await repo.adapter.currentRef()
885
+ }))
886
+ )
887
+ };
888
+ }
889
+ async function readManifestTool(repo) {
890
+ const manifest = await readManifest(repo);
891
+ return { manifest, provenance: await provenanceOf(repo, "ogygie.manifest.json") };
892
+ }
893
+ function specIdOf2(fileName) {
894
+ return fileName.replace(/\.spec\.md$/, "");
895
+ }
896
+ async function specFileNames(repo) {
897
+ const entries = await repo.adapter.listDirectory(SPECS_DIR);
898
+ return entries.map((entry) => entry.path).filter((path) => !path.includes("/") && path.endsWith(".spec.md") && !path.startsWith("_")).sort();
899
+ }
900
+ var FRONTMATTER_BLOCK = /^---\n([\s\S]*?)\n---/;
901
+ function parseSpecFile(content) {
902
+ const match = FRONTMATTER_BLOCK.exec(content);
903
+ if (match === null || match[1] === void 0) {
904
+ return {
905
+ frontmatter: null,
906
+ problems: ["file does not start with a --- ... --- frontmatter block"],
907
+ body: content
908
+ };
909
+ }
910
+ const body = content.slice(match[0].length);
911
+ const parsed = parseSpecFrontmatter(match[1]);
912
+ if (parsed.problems.length > 0) {
913
+ return { frontmatter: null, problems: parsed.problems, body };
914
+ }
915
+ const checked = SpecFrontmatterSchema.safeParse(parsed.value);
916
+ if (!checked.success) {
917
+ return {
918
+ frontmatter: null,
919
+ problems: checked.error.issues.map(
920
+ (issue) => `${issue.path.join(".")}: ${issue.message}`
921
+ ),
922
+ body
923
+ };
924
+ }
925
+ return { frontmatter: checked.data, problems: [], body };
926
+ }
927
+ async function listSpecs(repo, input) {
928
+ const specs = [];
929
+ const invalid = [];
930
+ for (const fileName of await specFileNames(repo)) {
931
+ const parsed = parseSpecFile(await readDocument(repo, `${SPECS_DIR}/${fileName}`));
932
+ if (parsed.frontmatter === null) {
933
+ invalid.push({ id: specIdOf2(fileName), problems: parsed.problems });
934
+ continue;
935
+ }
936
+ if (input.status !== void 0 && parsed.frontmatter.status !== input.status) continue;
937
+ specs.push({
938
+ id: parsed.frontmatter.id,
939
+ status: parsed.frontmatter.status,
940
+ priority: parsed.frontmatter.priority,
941
+ dependsOn: parsed.frontmatter.depends_on,
942
+ deferred: parsed.frontmatter.deferred
943
+ });
944
+ }
945
+ return { specs, invalid };
946
+ }
947
+ async function readSpec(repo, input) {
948
+ const path = `${SPECS_DIR}/${input.id}.spec.md`;
949
+ if (!await repo.adapter.exists(path)) {
950
+ const known = (await specFileNames(repo)).map(specIdOf2);
951
+ throwOgygieError({
952
+ code: "SPEC_NOT_FOUND",
953
+ message: `Repo "${repo.id}" has no spec "${input.id}".`,
954
+ file: path,
955
+ cause: `${path} does not exist`,
956
+ suggestedFix: `Existing specs: ${known.join(", ") || "<none>"}.`
957
+ });
958
+ }
959
+ const parsed = parseSpecFile(await readDocument(repo, path));
960
+ return {
961
+ frontmatter: parsed.frontmatter,
962
+ frontmatterProblems: parsed.problems,
963
+ body: parsed.body,
964
+ provenance: await provenanceOf(repo, path)
965
+ };
966
+ }
967
+ async function readDecisions(repo, input) {
968
+ const files = [];
969
+ for (const path of DECISION_FILES) {
970
+ if (await repo.adapter.exists(path)) {
971
+ files.push({ path, content: await readDocument(repo, path) });
972
+ }
973
+ }
974
+ if (input.decision === void 0) {
975
+ return {
976
+ documents: await Promise.all(
977
+ files.map(async (file) => ({
978
+ content: file.content,
979
+ provenance: await provenanceOf(repo, file.path)
980
+ }))
981
+ )
982
+ };
983
+ }
984
+ for (const file of files) {
985
+ const section = extractDecisionSection(file.content, input.decision);
986
+ if (section !== null) {
987
+ return { content: section, provenance: await provenanceOf(repo, file.path) };
988
+ }
989
+ }
990
+ const known = files.flatMap((file) => parseKnownDecisionIds(file.content));
991
+ return throwOgygieError({
992
+ code: "DECISION_NOT_FOUND",
993
+ message: `Decision ${input.decision} is declared in neither decisions file of repo "${repo.id}".`,
994
+ file: DECISION_FILES.join(" ; "),
995
+ cause: `declared decisions: ${known.join(", ") || "<none>"}`,
996
+ suggestedFix: "Cite a decision that exists, or call ogygie_read_decisions without `decision`."
997
+ });
998
+ }
999
+ async function readGovernance(repo, input) {
1000
+ return {
1001
+ content: await readDocument(repo, input.file),
1002
+ provenance: await provenanceOf(repo, input.file)
1003
+ };
1004
+ }
1005
+ async function readFeature(repo, input) {
1006
+ const manifest = await readManifest(repo);
1007
+ const owner = findSpecOwner(manifest, input.feature);
1008
+ if (owner === void 0 || owner.folder === null) {
1009
+ throwOgygieError({
1010
+ code: "FEATURE_NOT_FOUND",
1011
+ message: `The manifest of repo "${repo.id}" declares no feature "${input.feature}".`,
1012
+ file: "ogygie.manifest.json",
1013
+ cause: owner === void 0 ? "no feature and no package of that name in the map" : `${owner.kind} "${owner.name}" declares no folder`,
1014
+ suggestedFix: "Call ogygie_read_manifest to see what the repo declares \u2014 a feature absent from the map does not exist."
1015
+ });
1016
+ }
1017
+ const folder = owner.folder;
1018
+ if (input.file !== void 0) {
1019
+ const path = `${folder}/${input.file}`;
1020
+ return {
1021
+ content: await readDocument(repo, path),
1022
+ provenance: await provenanceOf(repo, path)
1023
+ };
1024
+ }
1025
+ return {
1026
+ feature: input.feature,
1027
+ files: await repo.adapter.listDirectory(folder),
1028
+ provenance: await provenanceOf(repo, folder)
1029
+ };
1030
+ }
1031
+
1032
+ // src/server.ts
1033
+ function asText(payload) {
1034
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
1035
+ }
1036
+ function asError(thrown) {
1037
+ const payload = thrown instanceof OgygieFailure ? thrown.error : {
1038
+ ogygie: true,
1039
+ code: "UNEXPECTED",
1040
+ message: thrown instanceof Error ? thrown.message : String(thrown),
1041
+ suggestedFix: "Report this to the Ogygie framework: a tool failed outside its contract."
1042
+ };
1043
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], isError: true };
1044
+ }
1045
+ function createOgygieMcpServer(deps) {
1046
+ const server = new McpServer(
1047
+ { name: "ogygie", version: deps.version },
1048
+ { capabilities: { tools: {} } }
1049
+ );
1050
+ const register = (name, description, inputSchema, run) => {
1051
+ server.registerTool(
1052
+ name,
1053
+ // The SDK derives the JSON Schema clients see from this raw shape, so
1054
+ // the contract is written once, in schemas.ts.
1055
+ { description, inputSchema },
1056
+ (async (input) => {
1057
+ const startedAt = Date.now();
1058
+ try {
1059
+ const result = await run(input);
1060
+ deps.log?.(
1061
+ JSON.stringify({
1062
+ tool: name,
1063
+ repo: input["repo"] ?? null,
1064
+ outcome: "ok",
1065
+ durationMs: Date.now() - startedAt
1066
+ })
1067
+ );
1068
+ return asText(result);
1069
+ } catch (thrown) {
1070
+ deps.log?.(
1071
+ JSON.stringify({
1072
+ tool: name,
1073
+ repo: input["repo"] ?? null,
1074
+ outcome: thrown instanceof OgygieFailure ? thrown.error.code : "UNEXPECTED",
1075
+ durationMs: Date.now() - startedAt
1076
+ })
1077
+ );
1078
+ return asError(thrown);
1079
+ }
1080
+ })
1081
+ );
1082
+ };
1083
+ register(
1084
+ "ogygie_list_repos",
1085
+ "Lists the Ogygie repos this server serves, with the Ogygie version each pins and the git ref currently read.",
1086
+ {},
1087
+ async () => listRepos(deps.repos)
1088
+ );
1089
+ register(
1090
+ "ogygie_read_manifest",
1091
+ "Reads the manifest of a repo: features, routes, packages, tenancy. The manifest is the index of an Ogygie repo \u2014 start here.",
1092
+ ReadManifestInput,
1093
+ async (input) => readManifestTool(repoOf(deps.repos, input.repo))
1094
+ );
1095
+ register(
1096
+ "ogygie_list_specs",
1097
+ "Lists the specs of a repo (id, status, priority, dependencies, deferred behaviors), optionally filtered by status. Specs whose frontmatter is broken are listed apart, never dropped.",
1098
+ ListSpecsInput,
1099
+ async (input) => listSpecs(repoOf(deps.repos, input.repo), input)
1100
+ );
1101
+ register(
1102
+ "ogygie_read_spec",
1103
+ "Reads one spec whole: parsed frontmatter and markdown body. A spec is the contract of a feature \u2014 its behaviors are what the code must prove.",
1104
+ ReadSpecInput,
1105
+ async (input) => readSpec(repoOf(deps.repos, input.repo), input)
1106
+ );
1107
+ register(
1108
+ "ogygie_read_decisions",
1109
+ "Reads the structuring decisions of a repo. With `decision` (D<n>), returns that one section; without, both decision files whole. A decision is never reopened, only cited.",
1110
+ ReadDecisionsInput,
1111
+ async (input) => readDecisions(repoOf(deps.repos, input.repo), input)
1112
+ );
1113
+ register(
1114
+ "ogygie_read_governance",
1115
+ "Reads a governance document of a repo: AGENTS.md (the rules of that repo's version), AGENTS.product.md (its product bindings) or FAILURES.md (its journal of failures).",
1116
+ ReadGovernanceInput,
1117
+ async (input) => readGovernance(repoOf(deps.repos, input.repo), input)
1118
+ );
1119
+ register(
1120
+ "ogygie_read_feature",
1121
+ "Lists the files of a feature declared in the manifest, or reads one of them with `file`. The folder comes from the map: a feature the manifest does not declare does not exist.",
1122
+ ReadFeatureInput,
1123
+ async (input) => readFeature(repoOf(deps.repos, input.repo), input)
1124
+ );
1125
+ return server;
1126
+ }
1127
+
1128
+ // src/transports.ts
1129
+ import { createServer } from "node:http";
1130
+ import { timingSafeEqual } from "node:crypto";
1131
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1132
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
1133
+ async function startStdioTransport(server) {
1134
+ await server.connect(new StdioServerTransport());
1135
+ }
1136
+ function tokenMatches(expected, provided) {
1137
+ const expectedBytes = Buffer.from(expected, "utf8");
1138
+ const providedBytes = Buffer.from(provided, "utf8");
1139
+ if (expectedBytes.length !== providedBytes.length) return false;
1140
+ return timingSafeEqual(expectedBytes, providedBytes);
1141
+ }
1142
+ function assertUsableToken(token) {
1143
+ if (token === void 0 || token.trim() === "") {
1144
+ return throwOgygieError({
1145
+ code: "TOKEN_MISSING",
1146
+ message: "The HTTP transport requires OGYGIE_MCP_TOKEN.",
1147
+ file: null,
1148
+ cause: "OGYGIE_MCP_TOKEN is unset or empty",
1149
+ suggestedFix: "Set OGYGIE_MCP_TOKEN to a random secret of at least 32 characters, or run the stdio transport, which needs none."
1150
+ });
1151
+ }
1152
+ if (token.length < MIN_TOKEN_LENGTH) {
1153
+ return throwOgygieError({
1154
+ code: "TOKEN_MISSING",
1155
+ message: "The OGYGIE_MCP_TOKEN is too short to be a secret.",
1156
+ file: null,
1157
+ cause: `length=${token.length} < ${MIN_TOKEN_LENGTH}`,
1158
+ suggestedFix: `Use at least ${MIN_TOKEN_LENGTH} random characters \u2014 e.g. \`openssl rand -hex 32\`.`
1159
+ });
1160
+ }
1161
+ return token;
1162
+ }
1163
+ function refuse(response, status, error) {
1164
+ response.writeHead(status, { "content-type": "application/json" });
1165
+ response.end(JSON.stringify(error));
1166
+ }
1167
+ function isAuthorized(authorization, expected) {
1168
+ if (authorization === void 0) return false;
1169
+ const [scheme, value] = authorization.split(" ");
1170
+ if (scheme?.toLowerCase() !== "bearer" || value === void 0) return false;
1171
+ return tokenMatches(expected, value);
1172
+ }
1173
+ async function startHttpTransport(createServerInstance, options) {
1174
+ const token = assertUsableToken(options.token);
1175
+ const handleWithFreshTransport = async (request, response, body) => {
1176
+ const server = createServerInstance();
1177
+ const transport = new StreamableHTTPServerTransport({});
1178
+ response.on("close", () => {
1179
+ void transport.close();
1180
+ void server.close();
1181
+ });
1182
+ await server.connect(transport);
1183
+ await transport.handleRequest(request, response, body);
1184
+ };
1185
+ const httpServer = createServer((request, response) => {
1186
+ if (!isAuthorized(request.headers.authorization, token)) {
1187
+ refuse(
1188
+ response,
1189
+ 401,
1190
+ ogygieError({
1191
+ code: "UNAUTHORIZED",
1192
+ message: "This Ogygie MCP server requires a bearer token.",
1193
+ file: null,
1194
+ cause: "missing or wrong Authorization header",
1195
+ suggestedFix: "Send `Authorization: Bearer <OGYGIE_MCP_TOKEN>`."
1196
+ })
1197
+ );
1198
+ return;
1199
+ }
1200
+ const chunks = [];
1201
+ request.on("data", (chunk) => chunks.push(chunk));
1202
+ request.on("end", () => {
1203
+ const raw = Buffer.concat(chunks).toString("utf8");
1204
+ let body;
1205
+ try {
1206
+ body = raw === "" ? void 0 : JSON.parse(raw);
1207
+ } catch {
1208
+ refuse(response, 400, { error: "invalid JSON body" });
1209
+ return;
1210
+ }
1211
+ void handleWithFreshTransport(request, response, body);
1212
+ });
1213
+ });
1214
+ const host = options.host ?? "127.0.0.1";
1215
+ await new Promise((resolve3) => httpServer.listen(options.port, host, resolve3));
1216
+ const address = httpServer.address();
1217
+ return {
1218
+ port: typeof address === "object" && address !== null ? address.port : options.port,
1219
+ close: () => new Promise(
1220
+ (resolve3, reject) => httpServer.close((error) => error === void 0 ? resolve3() : reject(error))
1221
+ )
1222
+ };
1223
+ }
1224
+
1225
+ // src/main.ts
1226
+ function parseArguments(argv) {
1227
+ const options = { configPath: null, http: false, port: 3010, host: void 0 };
1228
+ for (let index = 0; index < argv.length; index += 1) {
1229
+ const argument = argv[index];
1230
+ if (argument === "--config") options.configPath = argv[++index] ?? null;
1231
+ else if (argument === "--http") options.http = true;
1232
+ else if (argument === "--port") options.port = Number.parseInt(argv[++index] ?? "", 10);
1233
+ else if (argument === "--host") options.host = argv[++index];
1234
+ }
1235
+ return options;
1236
+ }
1237
+ function loadRegistry(options, cwd, readConfigFile) {
1238
+ if (options.configPath === null) {
1239
+ return { repos: [{ id: "local", rootPath: cwd }] };
1240
+ }
1241
+ const absolute = resolve2(cwd, options.configPath);
1242
+ let raw;
1243
+ try {
1244
+ raw = readConfigFile(absolute);
1245
+ } catch (thrown) {
1246
+ throw new OgygieFailure(
1247
+ ogygieError({
1248
+ code: "REGISTRY_INVALID",
1249
+ message: "The registry file cannot be read.",
1250
+ file: absolute,
1251
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
1252
+ suggestedFix: "Check the --config path, or omit it to serve the current project."
1253
+ })
1254
+ );
1255
+ }
1256
+ try {
1257
+ return parseRegistry(JSON.parse(raw));
1258
+ } catch (thrown) {
1259
+ if (thrown instanceof OgygieFailure) throw thrown;
1260
+ throw new OgygieFailure(
1261
+ ogygieError({
1262
+ code: "REGISTRY_INVALID",
1263
+ message: "The registry file is not valid JSON.",
1264
+ file: absolute,
1265
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
1266
+ suggestedFix: 'Write { "repos": [{ "id": "\u2026", "rootPath": "\u2026" }] }.'
1267
+ })
1268
+ );
1269
+ }
1270
+ }
1271
+ async function startOgygieMcp(options) {
1272
+ const parsed = parseArguments(options.argv);
1273
+ const registry = loadRegistry(parsed, options.cwd, options.readConfigFile);
1274
+ const repos = await openRepos(registry, {
1275
+ openRepo: (rootPath) => createFilesystemRepoAdapter(resolve2(options.cwd, rootPath)),
1276
+ serverVersion: options.version
1277
+ });
1278
+ const buildServer = () => createOgygieMcpServer({
1279
+ repos,
1280
+ version: options.version,
1281
+ log: (line) => process.stderr.write(`${line}
1282
+ `)
1283
+ });
1284
+ if (!parsed.http) {
1285
+ const server = buildServer();
1286
+ await startStdioTransport(server);
1287
+ return { close: async () => server.close() };
1288
+ }
1289
+ const running = await startHttpTransport(buildServer, {
1290
+ port: parsed.port,
1291
+ ...parsed.host === void 0 ? {} : { host: parsed.host },
1292
+ token: options.token
1293
+ });
1294
+ process.stderr.write(
1295
+ `ogygie-mcp \u2014 HTTP sur ${parsed.host ?? "127.0.0.1"}:${running.port}, ${repos.length} repo(s).
1296
+ `
1297
+ );
1298
+ return { close: () => running.close() };
1299
+ }
1300
+ export {
1301
+ loadRegistry,
1302
+ parseArguments,
1303
+ startOgygieMcp
1304
+ };