@ogygie/mcp 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/mcp.js +368 -30
  2. package/dist/server.js +368 -30
  3. package/package.json +8 -8
package/dist/mcp.js CHANGED
@@ -618,6 +618,7 @@ var ListSpecsInput = { repo: RepoIdSchema, status: SpecStatusSchema.optional() }
618
618
  var ReadSpecInput = { repo: RepoIdSchema, id: SpecIdSchema };
619
619
  var ReadDecisionsInput = { repo: RepoIdSchema, decision: DecisionIdSchema.optional() };
620
620
  var ReadGovernanceInput = { repo: RepoIdSchema, file: GovernanceFileSchema };
621
+ var ImpactInput = { repo: RepoIdSchema, feature: FeatureNameSchema2 };
621
622
  var ReadFeatureInput = {
622
623
  repo: RepoIdSchema,
623
624
  feature: FeatureNameSchema2,
@@ -805,46 +806,365 @@ function extractDecisionSection(decisionsMarkdown, decisionId) {
805
806
  return start === -1 ? null : trimTrailingBlanks(lines.slice(start));
806
807
  }
807
808
 
809
+ // ../ogygie-core/src/impact.ts
810
+ import { z as z6 } from "zod";
811
+
808
812
  // ../ogygie-core/src/manifest-owners.ts
809
813
  var ADVANCEMENT = ["skeleton", "in-progress", "delivered"];
810
814
  function specIdOf(specPath) {
811
815
  return (specPath.split("/").pop() ?? specPath).replace(/\.spec\.md$/, "");
812
816
  }
813
- function findSpecOwner(manifest, specId) {
814
- const feature = manifest.features.find((candidate) => candidate.name === specId);
815
- if (feature !== void 0) {
817
+ function declaredEntries(manifest) {
818
+ const entries = [];
819
+ for (const feature of manifest.features) {
816
820
  const owningApp = feature.app === void 0 ? manifest.apps.find((app) => app.type === "api") : manifest.apps.find((app) => app.name === feature.app);
817
- return {
821
+ entries.push({
818
822
  kind: "feature",
819
823
  name: feature.name,
820
824
  status: feature.status,
821
- folder: owningApp === void 0 ? null : `${owningApp.path}/${feature.folder}`
822
- };
825
+ folder: owningApp === void 0 ? null : `${owningApp.path}/${feature.folder}`,
826
+ specId: feature.name,
827
+ appType: owningApp?.type ?? null
828
+ });
823
829
  }
824
- const productPackage = (manifest.packages ?? []).find(
825
- (candidate) => candidate.name === specId
826
- );
827
- if (productPackage !== void 0) {
828
- return {
830
+ for (const productPackage of manifest.packages ?? []) {
831
+ entries.push({
829
832
  kind: "package",
830
833
  name: productPackage.name,
831
834
  status: productPackage.status,
832
- folder: productPackage.path
833
- };
835
+ folder: productPackage.path,
836
+ specId: productPackage.name,
837
+ appType: null
838
+ });
834
839
  }
835
- const frameworkPackages = (manifest.framework?.packages ?? []).filter(
836
- (candidate) => candidate.spec !== null && specIdOf(candidate.spec) === specId
837
- );
838
- const leastAdvanced = frameworkPackages.slice().sort((a, b) => ADVANCEMENT.indexOf(a.status) - ADVANCEMENT.indexOf(b.status))[0];
839
- if (leastAdvanced !== void 0) {
840
- return {
840
+ for (const frameworkPackage of manifest.framework?.packages ?? []) {
841
+ entries.push({
841
842
  kind: "framework-package",
842
- name: leastAdvanced.name,
843
- status: leastAdvanced.status,
844
- folder: leastAdvanced.path
845
- };
843
+ name: frameworkPackage.name,
844
+ status: frameworkPackage.status,
845
+ folder: frameworkPackage.path,
846
+ // A published npm name is not a spec id: the link is DECLARED.
847
+ specId: frameworkPackage.spec === null ? null : specIdOf(frameworkPackage.spec),
848
+ appType: null
849
+ });
850
+ }
851
+ return entries;
852
+ }
853
+ function findSpecOwner(manifest, specId) {
854
+ const owning = declaredEntries(manifest).filter((entry) => entry.specId === specId);
855
+ if (owning.length === 0) return void 0;
856
+ const byPriority = owning.slice().sort((left, right) => {
857
+ if (left.kind !== right.kind) {
858
+ const order = ["feature", "package", "framework-package"];
859
+ return order.indexOf(left.kind) - order.indexOf(right.kind);
860
+ }
861
+ return ADVANCEMENT.indexOf(left.status) - ADVANCEMENT.indexOf(right.status);
862
+ });
863
+ const owner = byPriority[0];
864
+ if (owner === void 0) return void 0;
865
+ return { kind: owner.kind, name: owner.name, status: owner.status, folder: owner.folder };
866
+ }
867
+
868
+ // ../ogygie-core/src/module-purity.ts
869
+ var IMPORT_STATEMENT = /\bimport\b(?![.(])([\s\S]*?)from\s*["']([^"']+)["']|\bimport\s+["']([^"']+)["']/g;
870
+ var DYNAMIC_LOAD = /\b(?:import\s*\(|require\s*\()\s*["']([^"']+)["']/g;
871
+ var REEXPORT_STATEMENT = /\bexport\s+(type\s+)?(\*(?:\s+as\s+[A-Za-z_$][\w$]*)?|\{[^}]*\})\s*from\s*["']([^"']+)["']/g;
872
+ function parseModuleImports(source) {
873
+ const code = stripComments(source);
874
+ const imports = [];
875
+ for (const match of code.matchAll(IMPORT_STATEMENT)) {
876
+ const [statement, clause, fromSpecifier, bareSpecifier] = match;
877
+ const specifier = fromSpecifier ?? bareSpecifier;
878
+ if (specifier === void 0) continue;
879
+ imports.push({
880
+ specifier,
881
+ typeOnly: clause !== void 0 && isTypeOnlyClause(clause),
882
+ statement: normalizeWhitespace(statement)
883
+ });
884
+ }
885
+ for (const match of code.matchAll(REEXPORT_STATEMENT)) {
886
+ const [statement, typeKeyword, clause, specifier] = match;
887
+ if (specifier === void 0 || clause === void 0) continue;
888
+ imports.push({
889
+ specifier,
890
+ typeOnly: typeKeyword !== void 0 || isTypeOnlyClause(clause),
891
+ statement: normalizeWhitespace(statement)
892
+ });
893
+ }
894
+ for (const match of code.matchAll(DYNAMIC_LOAD)) {
895
+ const [statement, specifier] = match;
896
+ if (specifier === void 0) continue;
897
+ imports.push({
898
+ specifier,
899
+ typeOnly: false,
900
+ statement: normalizeWhitespace(statement)
901
+ });
902
+ }
903
+ return imports;
904
+ }
905
+ function isTypeOnlyClause(clause) {
906
+ const trimmed = clause.trim();
907
+ if (trimmed.startsWith("type ")) return true;
908
+ const named = /^\{([\s\S]*)\}$/.exec(trimmed);
909
+ if (named === null || named[1] === void 0) return false;
910
+ const bindings = named[1].split(",").map((binding) => binding.trim()).filter((binding) => binding.length > 0);
911
+ return bindings.length > 0 && bindings.every((binding) => binding.startsWith("type "));
912
+ }
913
+ function stripComments(source) {
914
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
915
+ }
916
+ function normalizeWhitespace(statement) {
917
+ return statement.replace(/\s+/g, " ").trim();
918
+ }
919
+
920
+ // ../ogygie-core/src/shared-tables.ts
921
+ function dbTableNameOf(declaration) {
922
+ return typeof declaration === "string" ? declaration : declaration.name;
923
+ }
924
+ function dbTableNamesOf(feature) {
925
+ return feature.dbTables.map(dbTableNameOf);
926
+ }
927
+
928
+ // ../ogygie-core/src/impact.ts
929
+ var MapEntryKindSchema = z6.enum(["feature", "package", "framework-package"]);
930
+ var ImpactScopeSchema = z6.object({
931
+ scannedFolders: z6.array(z6.string()),
932
+ coveredKinds: z6.array(MapEntryKindSchema),
933
+ outOfReach: z6.array(z6.string())
934
+ });
935
+ var ImpactReportSchema = z6.object({
936
+ target: z6.object({
937
+ name: z6.string(),
938
+ kind: MapEntryKindSchema,
939
+ folder: z6.string()
940
+ }),
941
+ dependents: z6.array(
942
+ z6.object({
943
+ name: z6.string(),
944
+ kind: MapEntryKindSchema,
945
+ importedSymbols: z6.array(z6.string()),
946
+ viaIndexOnly: z6.boolean()
947
+ })
948
+ ),
949
+ routes: z6.array(
950
+ z6.object({ method: z6.string(), path: z6.string(), description: z6.string() })
951
+ ),
952
+ ownedTables: z6.array(z6.string()),
953
+ tableReadersOutsideOwner: z6.array(
954
+ z6.object({ table: z6.string(), feature: z6.string(), file: z6.string() })
955
+ ),
956
+ findings: z6.array(OgygieErrorSchema),
957
+ testCommand: z6.string(),
958
+ scope: ImpactScopeSchema
959
+ });
960
+ var SOURCE_EXTENSIONS = [".ts", ".tsx"];
961
+ function isSource(path) {
962
+ return SOURCE_EXTENSIONS.some((extension) => path.endsWith(extension));
963
+ }
964
+ function isTest(path) {
965
+ return /\.test\.tsx?$/.test(path);
966
+ }
967
+ function symbolsOfImportClause(clause) {
968
+ const symbols = [];
969
+ const braces = /\{([^}]*)\}/.exec(clause);
970
+ if (braces?.[1] !== void 0) {
971
+ for (const rawEntry of braces[1].split(",")) {
972
+ const name = rawEntry.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim();
973
+ if (name !== void 0 && name !== "") symbols.push(name);
974
+ }
975
+ }
976
+ const outsideBraces = clause.replace(/\{[^}]*\}/, "").replace(/^type\s+/, "").trim();
977
+ if (/\*\s*as\s+/.test(outsideBraces)) symbols.push("*");
978
+ else if (outsideBraces.replace(/,/g, "").trim() !== "") symbols.push("default");
979
+ return symbols;
980
+ }
981
+ function specifiersMatching(target, source) {
982
+ const folder = target.folder;
983
+ if (folder === null) return false;
984
+ const directory = folder.split("/").pop() ?? folder;
985
+ if (new RegExp(`(^|/)\\.\\./${directory}/`).test(source)) return true;
986
+ if (source.startsWith(`../${directory}/`) || source === `../${directory}`) return true;
987
+ if (source === target.name || source.startsWith(`${target.name}/`)) return true;
988
+ const segments = source.split("/");
989
+ return segments.includes(directory) && !source.startsWith(".");
990
+ }
991
+ function piercesTheIndex(specifier) {
992
+ if (!specifier.startsWith(".")) return false;
993
+ return !/(^|\/)index(\.js)?$/.test(specifier);
994
+ }
995
+ async function analyzeImpact(deps, targetName) {
996
+ const entries = declaredEntries(deps.manifest);
997
+ const target = entries.find((entry) => entry.name === targetName);
998
+ if (target === void 0) {
999
+ return throwOgygieError({
1000
+ code: "IMPACT_UNKNOWN_FEATURE",
1001
+ message: `"${targetName}" is unknown to the manifest.`,
1002
+ file: "ogygie.manifest.json",
1003
+ cause: `no entry of the map is named "${targetName}"`,
1004
+ suggestedFix: `Pick one of: ${entries.map((entry) => entry.name).join(", ") || "<the map declares nothing>"}.`
1005
+ });
846
1006
  }
847
- return void 0;
1007
+ return buildReport(deps, entries, target);
1008
+ }
1009
+ async function buildReport(deps, entries, target) {
1010
+ const findings = [];
1011
+ const scannedFolders = [];
1012
+ const outOfReach = [];
1013
+ if (target.folder === null) {
1014
+ outOfReach.push(
1015
+ `the target "${target.name}" has no folder the map can resolve \u2014 its app is not declared`
1016
+ );
1017
+ }
1018
+ const dependents = /* @__PURE__ */ new Map();
1019
+ const tableReadersOutsideOwner = [];
1020
+ const ownedTables = target.kind === "feature" ? tablesOf(deps.manifest, target.name) : [];
1021
+ for (const entry of entries) {
1022
+ if (entry.name === target.name) continue;
1023
+ if (entry.folder === null) {
1024
+ outOfReach.push(
1025
+ `"${entry.name}" (${entry.kind}) \u2014 the map declares no resolvable folder for it`
1026
+ );
1027
+ continue;
1028
+ }
1029
+ const files = (await deps.adapter.listDirectory(entry.folder)).filter(
1030
+ (file) => isSource(file.path)
1031
+ );
1032
+ if (files.length > 0) scannedFolders.push(entry.folder);
1033
+ for (const file of files) {
1034
+ const filePath = `${entry.folder}/${file.path}`;
1035
+ const content = await deps.adapter.readFile(filePath);
1036
+ for (const imported of parseModuleImports(content)) {
1037
+ if (!specifiersMatching(target, imported.specifier)) continue;
1038
+ const accumulator = dependents.get(entry.name) ?? {
1039
+ kind: entry.kind,
1040
+ importedSymbols: /* @__PURE__ */ new Set(),
1041
+ viaIndexOnly: true
1042
+ };
1043
+ for (const symbol of symbolsOfImportClause(clauseOf(imported.statement))) {
1044
+ accumulator.importedSymbols.add(symbol);
1045
+ }
1046
+ if (piercesTheIndex(imported.specifier)) {
1047
+ accumulator.viaIndexOnly = false;
1048
+ findings.push(
1049
+ ogygieError({
1050
+ code: "FEATURE_BOUNDARY_VIOLATION",
1051
+ message: `"${entry.name}" imports an internal file of "${target.name}" instead of its index.ts.`,
1052
+ file: filePath,
1053
+ cause: `import from "${imported.specifier}" bypasses the public index of ${target.name}`,
1054
+ suggestedFix: `Export the needed symbols from the index of ${target.name} and import them from there.`
1055
+ })
1056
+ );
1057
+ }
1058
+ dependents.set(entry.name, accumulator);
1059
+ }
1060
+ if (isTest(file.path)) continue;
1061
+ if (entry.appType === "front" || entry.appType === "gateway") continue;
1062
+ for (const table of ownedTables) {
1063
+ if (!content.includes(`"${table}"`)) continue;
1064
+ tableReadersOutsideOwner.push({ table, feature: entry.name, file: filePath });
1065
+ findings.push(
1066
+ ogygieError({
1067
+ code: "TABLE_REFERENCED_OUTSIDE_OWNER",
1068
+ message: `Table "${table}" is owned by "${target.name}" but referenced by production code of "${entry.name}".`,
1069
+ file: filePath,
1070
+ cause: `"${table}" found in a non-test file outside its owner`,
1071
+ suggestedFix: `Go through the public contracts of ${target.name} instead of touching the table \u2014 one table, one owner.`
1072
+ })
1073
+ );
1074
+ }
1075
+ }
1076
+ }
1077
+ findings.push(...manifestDependencyFindings(deps.manifest, target, dependents));
1078
+ const feature = deps.manifest.features.find((candidate) => candidate.name === target.name);
1079
+ const report = {
1080
+ target: { name: target.name, kind: target.kind, folder: target.folder ?? "" },
1081
+ dependents: [...dependents.entries()].map(([name, accumulator]) => ({
1082
+ name,
1083
+ kind: accumulator.kind,
1084
+ importedSymbols: [...accumulator.importedSymbols].sort(),
1085
+ viaIndexOnly: accumulator.viaIndexOnly
1086
+ })).sort((left, right) => left.name < right.name ? -1 : 1),
1087
+ routes: (feature?.routes ?? []).map((route) => ({
1088
+ method: route.method,
1089
+ path: route.path,
1090
+ description: route.description
1091
+ })),
1092
+ ownedTables,
1093
+ tableReadersOutsideOwner,
1094
+ findings,
1095
+ testCommand: testCommandFor(target, [...dependents.keys()], entries),
1096
+ scope: {
1097
+ scannedFolders: scannedFolders.sort(),
1098
+ coveredKinds: [...new Set(entries.map((entry) => entry.kind))],
1099
+ outOfReach: [...outOfReach, ...outOfReachOf(target, entries)]
1100
+ }
1101
+ };
1102
+ return ImpactReportSchema.parse(report);
1103
+ }
1104
+ function clauseOf(statement) {
1105
+ const match = /\bimport\b([\s\S]*?)\bfrom\b/.exec(statement);
1106
+ return match?.[1] ?? "";
1107
+ }
1108
+ function tablesOf(manifest, featureName) {
1109
+ const feature = manifest.features.find((candidate) => candidate.name === featureName);
1110
+ return feature === void 0 ? [] : dbTableNamesOf(feature);
1111
+ }
1112
+ function outOfReachOf(target, entries) {
1113
+ const limits = [
1114
+ "code that no manifest entry declares \u2014 the map is the only chart",
1115
+ "anything outside this repo: no network, no npm registry"
1116
+ ];
1117
+ const spared = entries.filter(
1118
+ (entry) => entry.appType === "front" || entry.appType === "gateway"
1119
+ );
1120
+ if (spared.length > 0 && target.kind === "feature") {
1121
+ limits.push(
1122
+ `table ownership was NOT checked in ${spared.length} front/gateway entr${spared.length === 1 ? "y" : "ies"} \u2014 that code holds no database client, so a table name there is a query key or a route`
1123
+ );
1124
+ }
1125
+ if (target.kind === "framework-package" || target.kind === "package") {
1126
+ limits.unshift(
1127
+ `consumers of "${target.name}" OUTSIDE this repo \u2014 it is a package, and a published package is imported by projects this analysis cannot see`
1128
+ );
1129
+ }
1130
+ return limits;
1131
+ }
1132
+ function manifestDependencyFindings(manifest, target, dependents) {
1133
+ const findings = [];
1134
+ const declared = manifest.features.filter(
1135
+ (feature) => feature.dependsOn.includes(target.name)
1136
+ );
1137
+ for (const feature of declared) {
1138
+ if (!dependents.has(feature.name)) {
1139
+ findings.push(
1140
+ ogygieError({
1141
+ code: "MANIFEST_DEPENDENCY_STALE",
1142
+ message: `Feature "${feature.name}" declares dependsOn "${target.name}" but never imports it.`,
1143
+ file: `ogygie.manifest.json#features.${feature.name}`,
1144
+ cause: `dependsOn contains "${target.name}", no import of it found in its code`,
1145
+ suggestedFix: "Remove the stale dependsOn entry, or make the dependency real through the entry's index.ts."
1146
+ })
1147
+ );
1148
+ }
1149
+ }
1150
+ for (const [name, accumulator] of dependents) {
1151
+ if (accumulator.kind !== "feature") continue;
1152
+ if (declared.some((feature) => feature.name === name)) continue;
1153
+ findings.push(
1154
+ ogygieError({
1155
+ code: "MANIFEST_DEPENDENCY_UNDECLARED",
1156
+ message: `Feature "${name}" imports "${target.name}" but does not declare it in dependsOn.`,
1157
+ file: `ogygie.manifest.json#features.${name}`,
1158
+ cause: `imports of "${target.name}" found in its code, absent from its dependsOn`,
1159
+ suggestedFix: `Add "${target.name}" to the dependsOn of "${name}" \u2014 the manifest must never lie by omission.`
1160
+ })
1161
+ );
1162
+ }
1163
+ return findings;
1164
+ }
1165
+ function testCommandFor(target, dependentNames, entries) {
1166
+ const folders = [target, ...entries.filter((entry) => dependentNames.includes(entry.name))].map((entry) => entry.folder).filter((folder) => folder !== null);
1167
+ return ["pnpm test --", ...[...new Set(folders)].sort()].join(" ");
848
1168
  }
849
1169
 
850
1170
  // src/tools.ts
@@ -1007,6 +1327,11 @@ async function readGovernance(repo, input) {
1007
1327
  provenance: await provenanceOf(repo, input.file)
1008
1328
  };
1009
1329
  }
1330
+ async function impact(repo, input) {
1331
+ const manifest = await readManifest(repo);
1332
+ const report = await analyzeImpact({ manifest, adapter: repo.adapter }, input.feature);
1333
+ return { ...report, provenance: await provenanceOf(repo, "ogygie.manifest.json") };
1334
+ }
1010
1335
  async function readFeature(repo, input) {
1011
1336
  const manifest = await readManifest(repo);
1012
1337
  const owner = findSpecOwner(manifest, input.feature);
@@ -1121,6 +1446,12 @@ function createOgygieMcpServer(deps) {
1121
1446
  ReadGovernanceInput,
1122
1447
  async (input) => readGovernance(repoOf(deps.repos, input.repo), input)
1123
1448
  );
1449
+ register(
1450
+ "ogygie_impact",
1451
+ "Blast radius of a change to a feature or package: who depends on it, which symbols they import, tables owned, tests to re-run \u2014 plus what the analysis could NOT see.",
1452
+ ImpactInput,
1453
+ async (input) => impact(repoOf(deps.repos, input.repo), input)
1454
+ );
1124
1455
  register(
1125
1456
  "ogygie_read_feature",
1126
1457
  "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.",
@@ -1169,11 +1500,18 @@ function refuse(response, status, error) {
1169
1500
  response.writeHead(status, { "content-type": "application/json" });
1170
1501
  response.end(JSON.stringify(error));
1171
1502
  }
1172
- function isAuthorized(authorization, expected) {
1173
- if (authorization === void 0) return false;
1174
- const [scheme, value] = authorization.split(" ");
1175
- if (scheme?.toLowerCase() !== "bearer" || value === void 0) return false;
1176
- return tokenMatches(expected, value);
1503
+ var SECRET_PATH_PREFIX = "/mcp-";
1504
+ function isAuthorized(authorization, expected, requestPath) {
1505
+ if (authorization !== void 0) {
1506
+ const [scheme, value] = authorization.split(" ");
1507
+ if (scheme?.toLowerCase() === "bearer" && value !== void 0) {
1508
+ if (tokenMatches(expected, value)) return true;
1509
+ }
1510
+ }
1511
+ if (requestPath === void 0) return false;
1512
+ const [pathOnly] = requestPath.split("?");
1513
+ if (pathOnly === void 0 || !pathOnly.startsWith(SECRET_PATH_PREFIX)) return false;
1514
+ return tokenMatches(expected, pathOnly.slice(SECRET_PATH_PREFIX.length));
1177
1515
  }
1178
1516
  async function startHttpTransport(createServerInstance, options) {
1179
1517
  const token = assertUsableToken(options.token);
@@ -1188,7 +1526,7 @@ async function startHttpTransport(createServerInstance, options) {
1188
1526
  await transport.handleRequest(request, response, body);
1189
1527
  };
1190
1528
  const httpServer = createServer((request, response) => {
1191
- if (!isAuthorized(request.headers.authorization, token)) {
1529
+ if (!isAuthorized(request.headers.authorization, token, request.url)) {
1192
1530
  refuse(
1193
1531
  response,
1194
1532
  401,
package/dist/server.js CHANGED
@@ -613,6 +613,7 @@ var ListSpecsInput = { repo: RepoIdSchema, status: SpecStatusSchema.optional() }
613
613
  var ReadSpecInput = { repo: RepoIdSchema, id: SpecIdSchema };
614
614
  var ReadDecisionsInput = { repo: RepoIdSchema, decision: DecisionIdSchema.optional() };
615
615
  var ReadGovernanceInput = { repo: RepoIdSchema, file: GovernanceFileSchema };
616
+ var ImpactInput = { repo: RepoIdSchema, feature: FeatureNameSchema2 };
616
617
  var ReadFeatureInput = {
617
618
  repo: RepoIdSchema,
618
619
  feature: FeatureNameSchema2,
@@ -800,46 +801,365 @@ function extractDecisionSection(decisionsMarkdown, decisionId) {
800
801
  return start === -1 ? null : trimTrailingBlanks(lines.slice(start));
801
802
  }
802
803
 
804
+ // ../ogygie-core/src/impact.ts
805
+ import { z as z6 } from "zod";
806
+
803
807
  // ../ogygie-core/src/manifest-owners.ts
804
808
  var ADVANCEMENT = ["skeleton", "in-progress", "delivered"];
805
809
  function specIdOf(specPath) {
806
810
  return (specPath.split("/").pop() ?? specPath).replace(/\.spec\.md$/, "");
807
811
  }
808
- function findSpecOwner(manifest, specId) {
809
- const feature = manifest.features.find((candidate) => candidate.name === specId);
810
- if (feature !== void 0) {
812
+ function declaredEntries(manifest) {
813
+ const entries = [];
814
+ for (const feature of manifest.features) {
811
815
  const owningApp = feature.app === void 0 ? manifest.apps.find((app) => app.type === "api") : manifest.apps.find((app) => app.name === feature.app);
812
- return {
816
+ entries.push({
813
817
  kind: "feature",
814
818
  name: feature.name,
815
819
  status: feature.status,
816
- folder: owningApp === void 0 ? null : `${owningApp.path}/${feature.folder}`
817
- };
820
+ folder: owningApp === void 0 ? null : `${owningApp.path}/${feature.folder}`,
821
+ specId: feature.name,
822
+ appType: owningApp?.type ?? null
823
+ });
818
824
  }
819
- const productPackage = (manifest.packages ?? []).find(
820
- (candidate) => candidate.name === specId
821
- );
822
- if (productPackage !== void 0) {
823
- return {
825
+ for (const productPackage of manifest.packages ?? []) {
826
+ entries.push({
824
827
  kind: "package",
825
828
  name: productPackage.name,
826
829
  status: productPackage.status,
827
- folder: productPackage.path
828
- };
830
+ folder: productPackage.path,
831
+ specId: productPackage.name,
832
+ appType: null
833
+ });
829
834
  }
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 {
835
+ for (const frameworkPackage of manifest.framework?.packages ?? []) {
836
+ entries.push({
836
837
  kind: "framework-package",
837
- name: leastAdvanced.name,
838
- status: leastAdvanced.status,
839
- folder: leastAdvanced.path
840
- };
838
+ name: frameworkPackage.name,
839
+ status: frameworkPackage.status,
840
+ folder: frameworkPackage.path,
841
+ // A published npm name is not a spec id: the link is DECLARED.
842
+ specId: frameworkPackage.spec === null ? null : specIdOf(frameworkPackage.spec),
843
+ appType: null
844
+ });
845
+ }
846
+ return entries;
847
+ }
848
+ function findSpecOwner(manifest, specId) {
849
+ const owning = declaredEntries(manifest).filter((entry) => entry.specId === specId);
850
+ if (owning.length === 0) return void 0;
851
+ const byPriority = owning.slice().sort((left, right) => {
852
+ if (left.kind !== right.kind) {
853
+ const order = ["feature", "package", "framework-package"];
854
+ return order.indexOf(left.kind) - order.indexOf(right.kind);
855
+ }
856
+ return ADVANCEMENT.indexOf(left.status) - ADVANCEMENT.indexOf(right.status);
857
+ });
858
+ const owner = byPriority[0];
859
+ if (owner === void 0) return void 0;
860
+ return { kind: owner.kind, name: owner.name, status: owner.status, folder: owner.folder };
861
+ }
862
+
863
+ // ../ogygie-core/src/module-purity.ts
864
+ var IMPORT_STATEMENT = /\bimport\b(?![.(])([\s\S]*?)from\s*["']([^"']+)["']|\bimport\s+["']([^"']+)["']/g;
865
+ var DYNAMIC_LOAD = /\b(?:import\s*\(|require\s*\()\s*["']([^"']+)["']/g;
866
+ var REEXPORT_STATEMENT = /\bexport\s+(type\s+)?(\*(?:\s+as\s+[A-Za-z_$][\w$]*)?|\{[^}]*\})\s*from\s*["']([^"']+)["']/g;
867
+ function parseModuleImports(source) {
868
+ const code = stripComments(source);
869
+ const imports = [];
870
+ for (const match of code.matchAll(IMPORT_STATEMENT)) {
871
+ const [statement, clause, fromSpecifier, bareSpecifier] = match;
872
+ const specifier = fromSpecifier ?? bareSpecifier;
873
+ if (specifier === void 0) continue;
874
+ imports.push({
875
+ specifier,
876
+ typeOnly: clause !== void 0 && isTypeOnlyClause(clause),
877
+ statement: normalizeWhitespace(statement)
878
+ });
879
+ }
880
+ for (const match of code.matchAll(REEXPORT_STATEMENT)) {
881
+ const [statement, typeKeyword, clause, specifier] = match;
882
+ if (specifier === void 0 || clause === void 0) continue;
883
+ imports.push({
884
+ specifier,
885
+ typeOnly: typeKeyword !== void 0 || isTypeOnlyClause(clause),
886
+ statement: normalizeWhitespace(statement)
887
+ });
888
+ }
889
+ for (const match of code.matchAll(DYNAMIC_LOAD)) {
890
+ const [statement, specifier] = match;
891
+ if (specifier === void 0) continue;
892
+ imports.push({
893
+ specifier,
894
+ typeOnly: false,
895
+ statement: normalizeWhitespace(statement)
896
+ });
897
+ }
898
+ return imports;
899
+ }
900
+ function isTypeOnlyClause(clause) {
901
+ const trimmed = clause.trim();
902
+ if (trimmed.startsWith("type ")) return true;
903
+ const named = /^\{([\s\S]*)\}$/.exec(trimmed);
904
+ if (named === null || named[1] === void 0) return false;
905
+ const bindings = named[1].split(",").map((binding) => binding.trim()).filter((binding) => binding.length > 0);
906
+ return bindings.length > 0 && bindings.every((binding) => binding.startsWith("type "));
907
+ }
908
+ function stripComments(source) {
909
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
910
+ }
911
+ function normalizeWhitespace(statement) {
912
+ return statement.replace(/\s+/g, " ").trim();
913
+ }
914
+
915
+ // ../ogygie-core/src/shared-tables.ts
916
+ function dbTableNameOf(declaration) {
917
+ return typeof declaration === "string" ? declaration : declaration.name;
918
+ }
919
+ function dbTableNamesOf(feature) {
920
+ return feature.dbTables.map(dbTableNameOf);
921
+ }
922
+
923
+ // ../ogygie-core/src/impact.ts
924
+ var MapEntryKindSchema = z6.enum(["feature", "package", "framework-package"]);
925
+ var ImpactScopeSchema = z6.object({
926
+ scannedFolders: z6.array(z6.string()),
927
+ coveredKinds: z6.array(MapEntryKindSchema),
928
+ outOfReach: z6.array(z6.string())
929
+ });
930
+ var ImpactReportSchema = z6.object({
931
+ target: z6.object({
932
+ name: z6.string(),
933
+ kind: MapEntryKindSchema,
934
+ folder: z6.string()
935
+ }),
936
+ dependents: z6.array(
937
+ z6.object({
938
+ name: z6.string(),
939
+ kind: MapEntryKindSchema,
940
+ importedSymbols: z6.array(z6.string()),
941
+ viaIndexOnly: z6.boolean()
942
+ })
943
+ ),
944
+ routes: z6.array(
945
+ z6.object({ method: z6.string(), path: z6.string(), description: z6.string() })
946
+ ),
947
+ ownedTables: z6.array(z6.string()),
948
+ tableReadersOutsideOwner: z6.array(
949
+ z6.object({ table: z6.string(), feature: z6.string(), file: z6.string() })
950
+ ),
951
+ findings: z6.array(OgygieErrorSchema),
952
+ testCommand: z6.string(),
953
+ scope: ImpactScopeSchema
954
+ });
955
+ var SOURCE_EXTENSIONS = [".ts", ".tsx"];
956
+ function isSource(path) {
957
+ return SOURCE_EXTENSIONS.some((extension) => path.endsWith(extension));
958
+ }
959
+ function isTest(path) {
960
+ return /\.test\.tsx?$/.test(path);
961
+ }
962
+ function symbolsOfImportClause(clause) {
963
+ const symbols = [];
964
+ const braces = /\{([^}]*)\}/.exec(clause);
965
+ if (braces?.[1] !== void 0) {
966
+ for (const rawEntry of braces[1].split(",")) {
967
+ const name = rawEntry.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim();
968
+ if (name !== void 0 && name !== "") symbols.push(name);
969
+ }
970
+ }
971
+ const outsideBraces = clause.replace(/\{[^}]*\}/, "").replace(/^type\s+/, "").trim();
972
+ if (/\*\s*as\s+/.test(outsideBraces)) symbols.push("*");
973
+ else if (outsideBraces.replace(/,/g, "").trim() !== "") symbols.push("default");
974
+ return symbols;
975
+ }
976
+ function specifiersMatching(target, source) {
977
+ const folder = target.folder;
978
+ if (folder === null) return false;
979
+ const directory = folder.split("/").pop() ?? folder;
980
+ if (new RegExp(`(^|/)\\.\\./${directory}/`).test(source)) return true;
981
+ if (source.startsWith(`../${directory}/`) || source === `../${directory}`) return true;
982
+ if (source === target.name || source.startsWith(`${target.name}/`)) return true;
983
+ const segments = source.split("/");
984
+ return segments.includes(directory) && !source.startsWith(".");
985
+ }
986
+ function piercesTheIndex(specifier) {
987
+ if (!specifier.startsWith(".")) return false;
988
+ return !/(^|\/)index(\.js)?$/.test(specifier);
989
+ }
990
+ async function analyzeImpact(deps, targetName) {
991
+ const entries = declaredEntries(deps.manifest);
992
+ const target = entries.find((entry) => entry.name === targetName);
993
+ if (target === void 0) {
994
+ return throwOgygieError({
995
+ code: "IMPACT_UNKNOWN_FEATURE",
996
+ message: `"${targetName}" is unknown to the manifest.`,
997
+ file: "ogygie.manifest.json",
998
+ cause: `no entry of the map is named "${targetName}"`,
999
+ suggestedFix: `Pick one of: ${entries.map((entry) => entry.name).join(", ") || "<the map declares nothing>"}.`
1000
+ });
841
1001
  }
842
- return void 0;
1002
+ return buildReport(deps, entries, target);
1003
+ }
1004
+ async function buildReport(deps, entries, target) {
1005
+ const findings = [];
1006
+ const scannedFolders = [];
1007
+ const outOfReach = [];
1008
+ if (target.folder === null) {
1009
+ outOfReach.push(
1010
+ `the target "${target.name}" has no folder the map can resolve \u2014 its app is not declared`
1011
+ );
1012
+ }
1013
+ const dependents = /* @__PURE__ */ new Map();
1014
+ const tableReadersOutsideOwner = [];
1015
+ const ownedTables = target.kind === "feature" ? tablesOf(deps.manifest, target.name) : [];
1016
+ for (const entry of entries) {
1017
+ if (entry.name === target.name) continue;
1018
+ if (entry.folder === null) {
1019
+ outOfReach.push(
1020
+ `"${entry.name}" (${entry.kind}) \u2014 the map declares no resolvable folder for it`
1021
+ );
1022
+ continue;
1023
+ }
1024
+ const files = (await deps.adapter.listDirectory(entry.folder)).filter(
1025
+ (file) => isSource(file.path)
1026
+ );
1027
+ if (files.length > 0) scannedFolders.push(entry.folder);
1028
+ for (const file of files) {
1029
+ const filePath = `${entry.folder}/${file.path}`;
1030
+ const content = await deps.adapter.readFile(filePath);
1031
+ for (const imported of parseModuleImports(content)) {
1032
+ if (!specifiersMatching(target, imported.specifier)) continue;
1033
+ const accumulator = dependents.get(entry.name) ?? {
1034
+ kind: entry.kind,
1035
+ importedSymbols: /* @__PURE__ */ new Set(),
1036
+ viaIndexOnly: true
1037
+ };
1038
+ for (const symbol of symbolsOfImportClause(clauseOf(imported.statement))) {
1039
+ accumulator.importedSymbols.add(symbol);
1040
+ }
1041
+ if (piercesTheIndex(imported.specifier)) {
1042
+ accumulator.viaIndexOnly = false;
1043
+ findings.push(
1044
+ ogygieError({
1045
+ code: "FEATURE_BOUNDARY_VIOLATION",
1046
+ message: `"${entry.name}" imports an internal file of "${target.name}" instead of its index.ts.`,
1047
+ file: filePath,
1048
+ cause: `import from "${imported.specifier}" bypasses the public index of ${target.name}`,
1049
+ suggestedFix: `Export the needed symbols from the index of ${target.name} and import them from there.`
1050
+ })
1051
+ );
1052
+ }
1053
+ dependents.set(entry.name, accumulator);
1054
+ }
1055
+ if (isTest(file.path)) continue;
1056
+ if (entry.appType === "front" || entry.appType === "gateway") continue;
1057
+ for (const table of ownedTables) {
1058
+ if (!content.includes(`"${table}"`)) continue;
1059
+ tableReadersOutsideOwner.push({ table, feature: entry.name, file: filePath });
1060
+ findings.push(
1061
+ ogygieError({
1062
+ code: "TABLE_REFERENCED_OUTSIDE_OWNER",
1063
+ message: `Table "${table}" is owned by "${target.name}" but referenced by production code of "${entry.name}".`,
1064
+ file: filePath,
1065
+ cause: `"${table}" found in a non-test file outside its owner`,
1066
+ suggestedFix: `Go through the public contracts of ${target.name} instead of touching the table \u2014 one table, one owner.`
1067
+ })
1068
+ );
1069
+ }
1070
+ }
1071
+ }
1072
+ findings.push(...manifestDependencyFindings(deps.manifest, target, dependents));
1073
+ const feature = deps.manifest.features.find((candidate) => candidate.name === target.name);
1074
+ const report = {
1075
+ target: { name: target.name, kind: target.kind, folder: target.folder ?? "" },
1076
+ dependents: [...dependents.entries()].map(([name, accumulator]) => ({
1077
+ name,
1078
+ kind: accumulator.kind,
1079
+ importedSymbols: [...accumulator.importedSymbols].sort(),
1080
+ viaIndexOnly: accumulator.viaIndexOnly
1081
+ })).sort((left, right) => left.name < right.name ? -1 : 1),
1082
+ routes: (feature?.routes ?? []).map((route) => ({
1083
+ method: route.method,
1084
+ path: route.path,
1085
+ description: route.description
1086
+ })),
1087
+ ownedTables,
1088
+ tableReadersOutsideOwner,
1089
+ findings,
1090
+ testCommand: testCommandFor(target, [...dependents.keys()], entries),
1091
+ scope: {
1092
+ scannedFolders: scannedFolders.sort(),
1093
+ coveredKinds: [...new Set(entries.map((entry) => entry.kind))],
1094
+ outOfReach: [...outOfReach, ...outOfReachOf(target, entries)]
1095
+ }
1096
+ };
1097
+ return ImpactReportSchema.parse(report);
1098
+ }
1099
+ function clauseOf(statement) {
1100
+ const match = /\bimport\b([\s\S]*?)\bfrom\b/.exec(statement);
1101
+ return match?.[1] ?? "";
1102
+ }
1103
+ function tablesOf(manifest, featureName) {
1104
+ const feature = manifest.features.find((candidate) => candidate.name === featureName);
1105
+ return feature === void 0 ? [] : dbTableNamesOf(feature);
1106
+ }
1107
+ function outOfReachOf(target, entries) {
1108
+ const limits = [
1109
+ "code that no manifest entry declares \u2014 the map is the only chart",
1110
+ "anything outside this repo: no network, no npm registry"
1111
+ ];
1112
+ const spared = entries.filter(
1113
+ (entry) => entry.appType === "front" || entry.appType === "gateway"
1114
+ );
1115
+ if (spared.length > 0 && target.kind === "feature") {
1116
+ limits.push(
1117
+ `table ownership was NOT checked in ${spared.length} front/gateway entr${spared.length === 1 ? "y" : "ies"} \u2014 that code holds no database client, so a table name there is a query key or a route`
1118
+ );
1119
+ }
1120
+ if (target.kind === "framework-package" || target.kind === "package") {
1121
+ limits.unshift(
1122
+ `consumers of "${target.name}" OUTSIDE this repo \u2014 it is a package, and a published package is imported by projects this analysis cannot see`
1123
+ );
1124
+ }
1125
+ return limits;
1126
+ }
1127
+ function manifestDependencyFindings(manifest, target, dependents) {
1128
+ const findings = [];
1129
+ const declared = manifest.features.filter(
1130
+ (feature) => feature.dependsOn.includes(target.name)
1131
+ );
1132
+ for (const feature of declared) {
1133
+ if (!dependents.has(feature.name)) {
1134
+ findings.push(
1135
+ ogygieError({
1136
+ code: "MANIFEST_DEPENDENCY_STALE",
1137
+ message: `Feature "${feature.name}" declares dependsOn "${target.name}" but never imports it.`,
1138
+ file: `ogygie.manifest.json#features.${feature.name}`,
1139
+ cause: `dependsOn contains "${target.name}", no import of it found in its code`,
1140
+ suggestedFix: "Remove the stale dependsOn entry, or make the dependency real through the entry's index.ts."
1141
+ })
1142
+ );
1143
+ }
1144
+ }
1145
+ for (const [name, accumulator] of dependents) {
1146
+ if (accumulator.kind !== "feature") continue;
1147
+ if (declared.some((feature) => feature.name === name)) continue;
1148
+ findings.push(
1149
+ ogygieError({
1150
+ code: "MANIFEST_DEPENDENCY_UNDECLARED",
1151
+ message: `Feature "${name}" imports "${target.name}" but does not declare it in dependsOn.`,
1152
+ file: `ogygie.manifest.json#features.${name}`,
1153
+ cause: `imports of "${target.name}" found in its code, absent from its dependsOn`,
1154
+ suggestedFix: `Add "${target.name}" to the dependsOn of "${name}" \u2014 the manifest must never lie by omission.`
1155
+ })
1156
+ );
1157
+ }
1158
+ return findings;
1159
+ }
1160
+ function testCommandFor(target, dependentNames, entries) {
1161
+ const folders = [target, ...entries.filter((entry) => dependentNames.includes(entry.name))].map((entry) => entry.folder).filter((folder) => folder !== null);
1162
+ return ["pnpm test --", ...[...new Set(folders)].sort()].join(" ");
843
1163
  }
844
1164
 
845
1165
  // src/tools.ts
@@ -1002,6 +1322,11 @@ async function readGovernance(repo, input) {
1002
1322
  provenance: await provenanceOf(repo, input.file)
1003
1323
  };
1004
1324
  }
1325
+ async function impact(repo, input) {
1326
+ const manifest = await readManifest(repo);
1327
+ const report = await analyzeImpact({ manifest, adapter: repo.adapter }, input.feature);
1328
+ return { ...report, provenance: await provenanceOf(repo, "ogygie.manifest.json") };
1329
+ }
1005
1330
  async function readFeature(repo, input) {
1006
1331
  const manifest = await readManifest(repo);
1007
1332
  const owner = findSpecOwner(manifest, input.feature);
@@ -1116,6 +1441,12 @@ function createOgygieMcpServer(deps) {
1116
1441
  ReadGovernanceInput,
1117
1442
  async (input) => readGovernance(repoOf(deps.repos, input.repo), input)
1118
1443
  );
1444
+ register(
1445
+ "ogygie_impact",
1446
+ "Blast radius of a change to a feature or package: who depends on it, which symbols they import, tables owned, tests to re-run \u2014 plus what the analysis could NOT see.",
1447
+ ImpactInput,
1448
+ async (input) => impact(repoOf(deps.repos, input.repo), input)
1449
+ );
1119
1450
  register(
1120
1451
  "ogygie_read_feature",
1121
1452
  "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.",
@@ -1164,11 +1495,18 @@ function refuse(response, status, error) {
1164
1495
  response.writeHead(status, { "content-type": "application/json" });
1165
1496
  response.end(JSON.stringify(error));
1166
1497
  }
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);
1498
+ var SECRET_PATH_PREFIX = "/mcp-";
1499
+ function isAuthorized(authorization, expected, requestPath) {
1500
+ if (authorization !== void 0) {
1501
+ const [scheme, value] = authorization.split(" ");
1502
+ if (scheme?.toLowerCase() === "bearer" && value !== void 0) {
1503
+ if (tokenMatches(expected, value)) return true;
1504
+ }
1505
+ }
1506
+ if (requestPath === void 0) return false;
1507
+ const [pathOnly] = requestPath.split("?");
1508
+ if (pathOnly === void 0 || !pathOnly.startsWith(SECRET_PATH_PREFIX)) return false;
1509
+ return tokenMatches(expected, pathOnly.slice(SECRET_PATH_PREFIX.length));
1172
1510
  }
1173
1511
  async function startHttpTransport(createServerInstance, options) {
1174
1512
  const token = assertUsableToken(options.token);
@@ -1183,7 +1521,7 @@ async function startHttpTransport(createServerInstance, options) {
1183
1521
  await transport.handleRequest(request, response, body);
1184
1522
  };
1185
1523
  const httpServer = createServer((request, response) => {
1186
- if (!isAuthorized(request.headers.authorization, token)) {
1524
+ if (!isAuthorized(request.headers.authorization, token, request.url)) {
1187
1525
  refuse(
1188
1526
  response,
1189
1527
  401,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogygie/mcp",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Ogygie MCP server: read-only access to the governance files and features of any Ogygie repo, the manifest as index. Ships built; runnable standalone or through `ogygie mcp`.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,20 +18,20 @@
18
18
  "exports": {
19
19
  "./server": "./dist/server.js"
20
20
  },
21
- "scripts": {
22
- "typecheck": "tsc -p tsconfig.json",
23
- "build": "tsx scripts/build.ts"
24
- },
25
21
  "dependencies": {
26
22
  "@modelcontextprotocol/sdk": "^1.30.0",
27
23
  "zod": "^4.0.0"
28
24
  },
29
25
  "devDependencies": {
30
- "@ogygie/core": "workspace:*",
31
26
  "@types/node": "^24.0.0",
32
27
  "esbuild": "^0.28.0",
33
28
  "tsx": "^4.20.0",
34
29
  "typescript": "^5.8.0",
35
- "vitest": "^3.2.0"
30
+ "vitest": "^3.2.0",
31
+ "@ogygie/core": "0.10.1"
32
+ },
33
+ "scripts": {
34
+ "typecheck": "tsc -p tsconfig.json",
35
+ "build": "tsx scripts/build.ts"
36
36
  }
37
- }
37
+ }