santismm-knowledge-mcp 0.4.0 → 0.4.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.
package/README.md CHANGED
@@ -20,6 +20,20 @@ repository:
20
20
 
21
21
  Docs: https://santismm.com/en/mcp · Registry: `com.santismm/knowledge`
22
22
 
23
+ ## Where this server is listed
24
+
25
+ Derived from the same list the site renders on its own MCP page, so a registry
26
+ added there is linked from here too — and a directory that checks for a backlink
27
+ before ranking a server finds one without anybody remembering to add it.
28
+
29
+ - [registry.modelcontextprotocol.io](https://registry.modelcontextprotocol.io/v0/servers?search=com.santismm/knowledge)
30
+ - [npmjs.com](https://www.npmjs.com/package/santismm-knowledge-mcp)
31
+ - [pypi.org](https://pypi.org/project/santismm-knowledge-mcp/)
32
+ - [github.com](https://github.com/santismm/santismm-knowledge-mcp)
33
+ - [smithery.ai](https://smithery.ai/servers/santismm/knowledge)
34
+ - [glama.ai](https://glama.ai/mcp/servers/santismm/santismm-knowledge-mcp)
35
+ - [mcpservers.org](https://mcpservers.org/servers/santismm/santismm-knowledge-mcp)
36
+
23
37
  ## Run it locally (stdio)
24
38
 
25
39
  ```bash
@@ -0,0 +1,205 @@
1
+ {
2
+ "source": "SANTISMM Labs catalogue",
3
+ "count": 21,
4
+ "results": [
5
+ {
6
+ "slug": "homeric-atlas",
7
+ "category": "experiments",
8
+ "status": "live",
9
+ "title": "Homeric Atlas",
10
+ "summary": "A probabilistic map of where the episodes of the Iliad and the Odyssey might have happened — each location classed as accepted, plausible, speculative or mythical, scored 0–12 against a published rubric, with sources, rival theories and reusable JSON/GeoJSON.",
11
+ "canonical_url": "https://santismm.com/en/labs/homeric-atlas",
12
+ "owner_server": "santismm.com"
13
+ },
14
+ {
15
+ "slug": "control-sandbox",
16
+ "category": "simulators",
17
+ "status": "live",
18
+ "title": "Agentic control sandbox",
19
+ "summary": "Interactive sandbox: configure an agent on six axes, pick an OWASP LLM Top 10 threat, switch the eighteen controls of the Agentic Control Matrix on and off, and read the coverage — quadrants filled, quadrants left empty, frameworks answered. Deliberately without a residual-risk score.",
20
+ "canonical_url": "https://santismm.com/en/labs/control-sandbox",
21
+ "owner_server": "santismm.com"
22
+ },
23
+ {
24
+ "slug": "agent-taxonomy",
25
+ "category": "experiments",
26
+ "status": "live",
27
+ "title": "Taxonomy of autonomous agents",
28
+ "summary": "An interactive taxonomy that encodes 24 AI agents — from OpenAI, Anthropic, Google, Microsoft, Amazon and SpaceXAI to the open-source frontier — as vectors across six orthogonal axes plus an action surface, with a filterable table, an A×T×I governance-risk matrix and a machine-readable JSON API.",
29
+ "canonical_url": "https://santismm.com/en/labs/agent-taxonomy",
30
+ "owner_server": "santismm.com"
31
+ },
32
+ {
33
+ "slug": "agent-economics",
34
+ "category": "calculators",
35
+ "status": "live",
36
+ "title": "The real economics of an AI agent",
37
+ "summary": "Estimate monthly operating cost, cost per correct outcome, savings, ROI and break-even from workload volume, token prices, tools, retries, human review and rework.",
38
+ "canonical_url": "https://labs.santismm.com/agent-economics",
39
+ "api_url": "https://labs.santismm.com/api/labs/agent-economics",
40
+ "owner_server": "labs.santismm.com"
41
+ },
42
+ {
43
+ "slug": "evaluation-sample-size",
44
+ "category": "calculators",
45
+ "status": "live",
46
+ "title": "How many evaluations do I need?",
47
+ "summary": "Calculate how many cases you need to detect at least one failure at a chosen confidence level and to estimate its frequency with an explicit margin.",
48
+ "canonical_url": "https://labs.santismm.com/evaluation-sample-size",
49
+ "api_url": "https://labs.santismm.com/api/labs/evaluation-sample-size",
50
+ "owner_server": "labs.santismm.com"
51
+ },
52
+ {
53
+ "slug": "human-supervision-capacity",
54
+ "category": "calculators",
55
+ "status": "live",
56
+ "title": "Human oversight capacity",
57
+ "summary": "Size review hours, escalations, FTE, cost, sustainable volume and backlog before putting an AI system into production.",
58
+ "canonical_url": "https://labs.santismm.com/human-supervision-capacity",
59
+ "api_url": "https://labs.santismm.com/api/labs/human-supervision-capacity",
60
+ "owner_server": "labs.santismm.com"
61
+ },
62
+ {
63
+ "slug": "agent-vector",
64
+ "category": "converters",
65
+ "status": "live",
66
+ "title": "Use case → agentic vector",
67
+ "summary": "Classify a system across six behavioural axes and its action surfaces, estimate its governance-risk band, surface control signals and find its nearest neighbours among 24 mapped agents.",
68
+ "canonical_url": "https://labs.santismm.com/agent-vector",
69
+ "api_url": "https://labs.santismm.com/api/labs/agent-vector",
70
+ "owner_server": "labs.santismm.com"
71
+ },
72
+ {
73
+ "slug": "control-framework-translator",
74
+ "category": "converters",
75
+ "status": "live",
76
+ "title": "Control and framework translator",
77
+ "summary": "Crosswalk the 18 Agentic Control Matrix controls to the EU AI Act, ISO 42001, NIST AI RMF, OWASP LLM Top 10 and MITRE ATLAS—with an explicit caveat: mapping is not compliance.",
78
+ "canonical_url": "https://labs.santismm.com/control-framework-translator",
79
+ "api_url": "https://labs.santismm.com/api/labs/control-framework-translator",
80
+ "owner_server": "labs.santismm.com"
81
+ },
82
+ {
83
+ "slug": "llm-context-converter",
84
+ "category": "converters",
85
+ "status": "live",
86
+ "title": "LLM context in human terms",
87
+ "summary": "Convert tokens into words, pages, documents, reading and speaking minutes, context-window usage and approximate input cost.",
88
+ "canonical_url": "https://labs.santismm.com/llm-context-converter",
89
+ "api_url": "https://labs.santismm.com/api/labs/llm-context-converter",
90
+ "owner_server": "labs.santismm.com"
91
+ },
92
+ {
93
+ "slug": "close-the-gap",
94
+ "category": "games",
95
+ "status": "live",
96
+ "title": "Close the gap",
97
+ "summary": "A five-round educational game: choose the right governance controls under a limited budget, close each agent's essential gaps and learn from immediate explanations.",
98
+ "canonical_url": "https://labs.santismm.com/close-the-gap",
99
+ "api_url": "https://labs.santismm.com/api/labs/close-the-gap",
100
+ "owner_server": "labs.santismm.com"
101
+ },
102
+ {
103
+ "slug": "model-agent-harness",
104
+ "category": "games",
105
+ "status": "live",
106
+ "title": "Model, agent or harness?",
107
+ "summary": "Twelve cases to distinguish the base capability, the entity pursuing goals and the infrastructure connecting context, memory, tools and controls.",
108
+ "canonical_url": "https://labs.santismm.com/model-agent-harness",
109
+ "api_url": "https://labs.santismm.com/api/labs/model-agent-harness",
110
+ "owner_server": "labs.santismm.com"
111
+ },
112
+ {
113
+ "slug": "benchmark-detective",
114
+ "category": "games",
115
+ "status": "live",
116
+ "title": "Benchmark detective",
117
+ "summary": "Investigate AI claims, choose the evidence that actually tests them and spot saturation, contamination, uncertainty and misleading metrics.",
118
+ "canonical_url": "https://labs.santismm.com/benchmark-detective",
119
+ "api_url": "https://labs.santismm.com/api/labs/benchmark-detective",
120
+ "owner_server": "labs.santismm.com"
121
+ },
122
+ {
123
+ "slug": "world-exam-challenge",
124
+ "category": "games",
125
+ "status": "live",
126
+ "title": "World exam challenge",
127
+ "summary": "A twelve-round game about 18 university-entrance systems: compare demandingness, identify countries and separate cognitive profile from selective pressure.",
128
+ "canonical_url": "https://labs.santismm.com/world-exam-challenge",
129
+ "api_url": "https://labs.santismm.com/api/labs/world-exam-challenge",
130
+ "owner_server": "labs.santismm.com"
131
+ },
132
+ {
133
+ "slug": "profesiones-ia",
134
+ "category": "experiments",
135
+ "status": "live",
136
+ "title": "The professions most exposed to AI",
137
+ "summary": "An interactive map of the 22 occupational groups, comparing AI's theoretical capability against its observed real-world use (as of June 2026), with a category ranking, the most-exposed occupations and the macro figures — rigorously sourced.",
138
+ "canonical_url": "https://santismm.com/en/labs/profesiones-ia",
139
+ "owner_server": "santismm.com"
140
+ },
141
+ {
142
+ "slug": "ai-agent-benchmarks",
143
+ "category": "benchmarks",
144
+ "status": "snapshot",
145
+ "title": "AI Agent Benchmarks",
146
+ "summary": "A saturation tracker for AI-agent benchmarks: what they measure, SOTA, headroom, the human gap and research priority.",
147
+ "canonical_url": "https://benchmarks.santismm.com/",
148
+ "owner_server": "santismm.com"
149
+ },
150
+ {
151
+ "slug": "exigencia",
152
+ "category": "benchmarks",
153
+ "status": "live",
154
+ "title": "How demanding is each exam in the world?",
155
+ "summary": "An interactive benchmark comparing the academic demandingness of 18 university-entrance exams (PAES, PAU, IB, SAT, Gaokao, Suneung, JEE…) across 7 dimensions, with a world map, radars and ranking.",
156
+ "canonical_url": "https://santismm.com/labs/exigencia",
157
+ "owner_server": "santismm.com"
158
+ },
159
+ {
160
+ "slug": "paes-pau-ib",
161
+ "category": "benchmarks",
162
+ "status": "live",
163
+ "title": "PAES vs PAU vs IB",
164
+ "summary": "An interactive benchmark comparing the academic demandingness of Chile's PAES, Spain's PAU and the IB Diploma across 6 cognitive dimensions and 8 subjects, with radar charts.",
165
+ "canonical_url": "https://santismm.com/labs/paes-pau-ib",
166
+ "owner_server": "santismm.com"
167
+ },
168
+ {
169
+ "slug": "mundial-del-dinero",
170
+ "category": "experiments",
171
+ "status": "live",
172
+ "title": "The money World Cup",
173
+ "summary": "Which national team punches above its squad value at the 2026 World Cup. A live efficiency index — points vs €-value — team by team.",
174
+ "canonical_url": "https://santismm.com/en/labs/mundial-del-dinero",
175
+ "owner_server": "santismm.com"
176
+ },
177
+ {
178
+ "slug": "surf-atlas",
179
+ "category": "experiments",
180
+ "status": "live",
181
+ "title": "World Surf Atlas",
182
+ "summary": "An interactive atlas of 100+ world-class waves — wave direction (left / right / both), break type, level and month-by-month average wave height, plotted on a world map.",
183
+ "canonical_url": "https://santismm.com/en/sports/surf",
184
+ "owner_server": "santismm.com"
185
+ },
186
+ {
187
+ "slug": "ski-atlas",
188
+ "category": "experiments",
189
+ "status": "live",
190
+ "title": "World Ski Atlas",
191
+ "summary": "130+ ski resorts on a world map — skiable km, runs by difficulty, snow quality, monthly snowfall, temperature and sun, plus lift-pass prices.",
192
+ "canonical_url": "https://santismm.com/en/sports/ski",
193
+ "owner_server": "santismm.com"
194
+ },
195
+ {
196
+ "slug": "ebike-atlas",
197
+ "category": "experiments",
198
+ "status": "live",
199
+ "title": "World Bike Park Atlas",
200
+ "summary": "40+ lift-served MTB / e-bike parks worldwide — trails by difficulty, vertical drop, km, season window and day-pass prices, on a world map.",
201
+ "canonical_url": "https://santismm.com/en/sports/ebike",
202
+ "owner_server": "santismm.com"
203
+ }
204
+ ]
205
+ }
@@ -10,5 +10,4 @@ thumbnail: "https://i.ytimg.com/vi/eC66YJDR8yA/hqdefault.jpg"
10
10
  canonicalUrl: "https://www.youtube.com/watch?v=eC66YJDR8yA"
11
11
  links:
12
12
  - { platform: "youtube", lang: "en", url: "https://www.youtube.com/watch?v=eC66YJDR8yA" }
13
- - { platform: "spotify", lang: "en", url: "https://open.spotify.com/show/4ESPCD6LXZGHsbYbV9KSQ7", label: "Listen on Spotify" }
14
13
  ---
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "slug": "reflection",
3
+ "aliases": ["PAT-003"],
3
4
  "category": "reliability",
4
5
  "updated": "2026-06-21",
5
6
  "version": "1.0",
package/dist/articles.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { norm, queryTerms } from "./shape.js";
1
2
  /**
2
3
  * Federated first-party article corpus.
3
4
  *
@@ -30,7 +31,7 @@ function validArticle(value) {
30
31
  }
31
32
  async function fetchCorpus() {
32
33
  const response = await fetch(ARTICLES_API_URL, {
33
- headers: { Accept: "application/json", "User-Agent": "santismm-knowledge-mcp/0.4.0" },
34
+ headers: { Accept: "application/json", "User-Agent": "santismm-knowledge-mcp/0.4.1" },
34
35
  signal: AbortSignal.timeout(8_000),
35
36
  cache: "no-store",
36
37
  });
@@ -71,16 +72,10 @@ export function articlesForLocale(articles, locale) {
71
72
  return articles;
72
73
  return articles.filter((article) => article.language.toLowerCase().split("-")[0] === locale);
73
74
  }
74
- function normalise(value) {
75
- return value
76
- .normalize("NFD")
77
- .replace(/[\u0300-\u036f]/g, "")
78
- .toLowerCase();
79
- }
80
75
  /** Ranked accent-insensitive full-text search over the canonical API payload. */
81
76
  export function searchArticleCorpus(articles, query, limit) {
82
- const terms = [...new Set(normalise(query).split(/[^a-z0-9]+/).filter((term) => term.length > 1))];
83
- const phrase = normalise(query).trim();
77
+ const terms = queryTerms(query);
78
+ const phrase = norm(query).trim();
84
79
  if (terms.length === 0)
85
80
  return [];
86
81
  const weights = [
@@ -96,7 +91,7 @@ export function searchArticleCorpus(articles, query, limit) {
96
91
  const matchedTerms = new Set();
97
92
  let score = 0;
98
93
  for (const [field, weight] of weights) {
99
- const value = normalise(Array.isArray(article[field]) ? article.topics.join(" ") : String(article[field]));
94
+ const value = norm(Array.isArray(article[field]) ? article.topics.join(" ") : String(article[field]));
100
95
  for (const term of terms) {
101
96
  if (!value.includes(term))
102
97
  continue;
@@ -107,9 +102,10 @@ export function searchArticleCorpus(articles, query, limit) {
107
102
  if (phrase.length > 2 && value.includes(phrase))
108
103
  score += weight * 2;
109
104
  }
105
+ const coverage = matchedTerms.size / terms.length;
110
106
  return {
111
107
  ...articleCard(article),
112
- score,
108
+ score: Math.round(score * coverage * coverage * 100) / 100,
113
109
  matchedFields: [...matchedFields],
114
110
  matchedTerms: [...matchedTerms],
115
111
  };
package/dist/content.js CHANGED
@@ -191,4 +191,19 @@ function loadClaims() {
191
191
  .filter((f) => f.endsWith(".json"))
192
192
  .map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")));
193
193
  }
194
- export const fsContent = makeContent(loadAll, loadHandbook, loadHomeric, loadClaims);
194
+ /**
195
+ * El catálogo de labs, desde la copia generada que viaja con el corpus.
196
+ *
197
+ * El paquete publicado no ve `lib/labs.ts`, así que sin este fichero el CLI
198
+ * serviría diez labs mientras el ápice anuncia veintiuno. Un catálogo ausente
199
+ * se lee como cero, no como error: por eso el validador comprueba que existe
200
+ * y que coincide con lo que saldría de regenerarlo.
201
+ */
202
+ function loadSiteLabs() {
203
+ const file = path.join(CONTENT_ROOT, "labs", "catalogue.json");
204
+ if (!fs.existsSync(file))
205
+ return [];
206
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
207
+ return Array.isArray(raw.results) ? raw.results : [];
208
+ }
209
+ export const fsContent = makeContent(loadAll, loadHandbook, loadHomeric, loadClaims, loadSiteLabs);
package/dist/labs.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { norm, queryTerms } from "./shape.js";
1
2
  /**
2
3
  * Federated SANTISMM Labs catalogue and deterministic calculators.
3
4
  *
@@ -36,7 +37,7 @@ function validLab(value) {
36
37
  }
37
38
  async function fetchCorpus() {
38
39
  const response = await fetch(LABS_API_URL, {
39
- headers: { Accept: 'application/json', 'User-Agent': 'santismm-knowledge-mcp/0.4.0' },
40
+ headers: { Accept: 'application/json', 'User-Agent': 'santismm-knowledge-mcp/0.4.1' },
40
41
  signal: AbortSignal.timeout(8_000),
41
42
  cache: 'no-store',
42
43
  });
@@ -67,12 +68,9 @@ export async function loadLabs() {
67
68
  }
68
69
  return (await pending).results;
69
70
  }
70
- function normalise(value) {
71
- return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
72
- }
73
71
  export function searchLabCorpus(labs, query, limit) {
74
- const terms = [...new Set(normalise(query).split(/[^a-z0-9]+/).filter((term) => term.length > 1))];
75
- const phrase = normalise(query).trim();
72
+ const terms = queryTerms(query);
73
+ const phrase = norm(query).trim();
76
74
  if (terms.length === 0)
77
75
  return [];
78
76
  const fields = [
@@ -86,7 +84,7 @@ export function searchLabCorpus(labs, query, limit) {
86
84
  let score = 0;
87
85
  for (const [field, weight] of fields) {
88
86
  const raw = lab[field];
89
- const value = normalise(Array.isArray(raw) ? raw.join(' ') : String(raw ?? ''));
87
+ const value = norm(Array.isArray(raw) ? raw.join(' ') : String(raw ?? ''));
90
88
  for (const term of terms) {
91
89
  if (!value.includes(term))
92
90
  continue;
@@ -97,19 +95,25 @@ export function searchLabCorpus(labs, query, limit) {
97
95
  if (phrase.length > 2 && value.includes(phrase))
98
96
  score += weight * 2;
99
97
  }
100
- return { ...lab, score, matchedFields: [...matchedFields], matchedTerms: [...matchedTerms] };
98
+ const coverage = matchedTerms.size / terms.length;
99
+ return {
100
+ ...lab,
101
+ score: Math.round(score * coverage * coverage * 100) / 100,
102
+ matchedFields: [...matchedFields],
103
+ matchedTerms: [...matchedTerms],
104
+ };
101
105
  })
102
106
  .filter((lab) => lab.score > 0)
103
107
  .sort((a, b) => b.score - a.score || b.updated.localeCompare(a.updated) || a.slug.localeCompare(b.slug))
104
108
  .slice(0, limit);
105
109
  }
106
- export async function executeLabCalculator(slug, inputs) {
107
- const response = await fetch(`${LABS_SERVICE_ORIGIN}/api/calculate/${slug}`, {
110
+ export async function executeLabCalculator(slug, inputs, locale = 'en') {
111
+ const response = await fetch(`${LABS_SERVICE_ORIGIN}/api/calculate/${slug}?locale=${locale}`, {
108
112
  method: 'POST',
109
113
  headers: {
110
114
  Accept: 'application/json',
111
115
  'Content-Type': 'application/json',
112
- 'User-Agent': 'santismm-knowledge-mcp/0.4.0',
116
+ 'User-Agent': 'santismm-knowledge-mcp/0.4.1',
113
117
  },
114
118
  body: JSON.stringify(inputs),
115
119
  signal: AbortSignal.timeout(8_000),
@@ -0,0 +1,163 @@
1
+ /**
2
+ * MCP result semantics, independent of the analytics backend.
3
+ *
4
+ * A transport can observe a handler result without importing Redis, and the
5
+ * stdio server can keep using the same registry without recording anything.
6
+ * The important boundary is the handler's FINAL result: a federated
7
+ * `search_all` is successful when any selected surface returned a hit, even
8
+ * when the local core sub-search was empty.
9
+ */
10
+ import { SEARCH_SURFACES } from "./surfaces.js";
11
+ export const MCP_OUTCOMES = ["ok", "partial", "empty", "not_found", "error"];
12
+ export const MCP_DEMAND_CLASSES = [
13
+ "answered",
14
+ "partial_answer",
15
+ "content_gap",
16
+ "wrong_tool",
17
+ "invalid_identifier",
18
+ "surface_unavailable",
19
+ "catalogue_empty",
20
+ "execution_error",
21
+ ];
22
+ const GET_DETAIL = {
23
+ get_knowledge: { domain: "knowledge", argument: "slug" },
24
+ get_pattern: { domain: "patterns", argument: "slug" },
25
+ get_architecture: { domain: "architectures", argument: "slug" },
26
+ get_governance: { domain: "governance", argument: "slug" },
27
+ get_handbook: { domain: "handbook", argument: "id" },
28
+ get_article: { domain: "articles", argument: "slug" },
29
+ get_lab: { domain: "labs", argument: "slug" },
30
+ get_homeric_place: { domain: "homeric/places", argument: "slug" },
31
+ get_homeric_episode: { domain: "homeric/episodes", argument: "slug" },
32
+ get_homeric_route: { domain: "homeric/routes", argument: "slug" },
33
+ get_claim: { domain: "claims", argument: "id" },
34
+ };
35
+ export function lookupRequestForTool(tool, args) {
36
+ if (tool === "get_related") {
37
+ const domain = boundedText(args.domain);
38
+ const identifier = boundedText(args.slug);
39
+ return domain && identifier ? { domain, identifier } : undefined;
40
+ }
41
+ const descriptor = GET_DETAIL[tool];
42
+ if (!descriptor)
43
+ return undefined;
44
+ const identifier = boundedText(args[descriptor.argument]);
45
+ return identifier ? { domain: descriptor.domain, identifier } : undefined;
46
+ }
47
+ function record(value) {
48
+ return value && typeof value === "object" ? value : undefined;
49
+ }
50
+ function errorBody(result) {
51
+ const first = result.content?.find((part) => part.type === "text" && typeof part.text === "string");
52
+ if (!first?.text)
53
+ return undefined;
54
+ try {
55
+ return record(JSON.parse(first.text));
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ function boundedText(value) {
62
+ if (typeof value !== "string")
63
+ return undefined;
64
+ const clean = value.trim();
65
+ return clean ? clean.slice(0, 100) : undefined;
66
+ }
67
+ /** The privacy-bounded label stored for consumption and unresolved rankings. */
68
+ export function detailForTool(tool, args) {
69
+ if (tool.startsWith("search")) {
70
+ const query = boundedText(args.query);
71
+ return query ? `search: ${query}` : undefined;
72
+ }
73
+ const lookup = lookupRequestForTool(tool, args);
74
+ return lookup ? `${lookup.domain}/${lookup.identifier}` : undefined;
75
+ }
76
+ function globalSearchDetail(args, structured) {
77
+ const selected = Array.isArray(args.surfaces)
78
+ ? args.surfaces.filter((surface) => typeof surface === "string")
79
+ : [...SEARCH_SURFACES];
80
+ const results = Array.isArray(structured?.results) ? structured.results : [];
81
+ const top = record(results[0]);
82
+ const unavailableRows = Array.isArray(structured?.unavailable_surfaces)
83
+ ? structured.unavailable_surfaces
84
+ : [];
85
+ return {
86
+ queried: selected,
87
+ topSurface: typeof top?.surface === "string" ? top.surface : undefined,
88
+ suggestedTool: typeof top?.suggested_tool === "string" ? top.suggested_tool : undefined,
89
+ unavailable: unavailableRows
90
+ .map((row) => record(row)?.surface)
91
+ .filter((surface) => typeof surface === "string"),
92
+ };
93
+ }
94
+ /**
95
+ * Classify the response the caller received. No sub-search, loader or counter
96
+ * is consulted here; the final handler payload is the source of truth.
97
+ */
98
+ export function summarizeToolResult(event) {
99
+ const detail = detailForTool(event.tool, event.args);
100
+ if (event.error !== undefined) {
101
+ return { tool: event.tool, outcome: "error", demandClass: "execution_error", detail };
102
+ }
103
+ const result = record(event.result);
104
+ if (!result) {
105
+ return { tool: event.tool, outcome: "error", demandClass: "execution_error", detail };
106
+ }
107
+ const structured = record(result.structuredContent);
108
+ const globalSearch = event.tool === "search_all" ? globalSearchDetail(event.args, structured) : undefined;
109
+ if (result.isError) {
110
+ const body = errorBody(result);
111
+ const code = typeof body?.error === "string" ? body.error : "error";
112
+ const foundIn = record(body?.found_in);
113
+ if (code === "not_found") {
114
+ const exact = typeof foundIn?.domain === "string" &&
115
+ typeof foundIn?.id === "string" &&
116
+ typeof foundIn?.tool === "string"
117
+ ? { domain: foundIn.domain, id: foundIn.id, tool: foundIn.tool }
118
+ : undefined;
119
+ return {
120
+ tool: event.tool,
121
+ outcome: "not_found",
122
+ demandClass: exact ? "wrong_tool" : "invalid_identifier",
123
+ detail,
124
+ foundIn: exact,
125
+ globalSearch,
126
+ };
127
+ }
128
+ if (code.includes("unavailable") || code.includes("failed")) {
129
+ return { tool: event.tool, outcome: "error", demandClass: "surface_unavailable", detail, globalSearch };
130
+ }
131
+ return { tool: event.tool, outcome: "error", demandClass: "execution_error", detail, globalSearch };
132
+ }
133
+ const count = typeof structured?.count === "number" ? structured.count : undefined;
134
+ const unavailable = globalSearch?.unavailable.length ?? 0;
135
+ if (event.tool === "search_all" && unavailable > 0) {
136
+ if (!count || count === 0) {
137
+ return {
138
+ tool: event.tool,
139
+ outcome: "error",
140
+ demandClass: "surface_unavailable",
141
+ detail,
142
+ globalSearch,
143
+ };
144
+ }
145
+ return {
146
+ tool: event.tool,
147
+ outcome: "partial",
148
+ demandClass: "partial_answer",
149
+ detail,
150
+ globalSearch,
151
+ };
152
+ }
153
+ if (count === 0) {
154
+ return {
155
+ tool: event.tool,
156
+ outcome: "empty",
157
+ demandClass: event.tool.startsWith("search") ? "content_gap" : "catalogue_empty",
158
+ detail,
159
+ globalSearch,
160
+ };
161
+ }
162
+ return { tool: event.tool, outcome: "ok", demandClass: "answered", detail, globalSearch };
163
+ }
package/dist/shape.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { SURFACE_TOOLS } from "./surfaces.js";
1
2
  /**
2
3
  * Framework-agnostic shaping layer for the MCP corpus.
3
4
  *
@@ -161,14 +162,19 @@ const STOPWORDS = new Set([
161
162
  // en
162
163
  "the", "a", "an", "of", "for", "and", "or", "to", "in", "on", "with", "is",
163
164
  "are", "what", "how", "why", "when", "that", "this", "it", "as", "by", "from",
164
- "at", "be", "can", "do", "does",
165
+ "at", "be", "can", "do", "does", "where", "who", "which", "would",
166
+ "could", "should", "need", "was", "were", "has", "have", "had", "my",
167
+ "your", "their", "about", "into", "than", "then",
165
168
  // es
166
169
  "el", "la", "los", "las", "un", "una", "unos", "unas", "de", "del", "y", "o",
167
170
  "para", "por", "con", "en", "que", "es", "son", "como", "cual", "cuando",
168
- "sobre", "al", "se", "su", "sus", "antes", "despues", "mas",
171
+ "sobre", "al", "se", "su", "sus", "antes", "despues", "mas", "donde",
172
+ "quien", "necesito", "necesita", "hay", "ha", "han", "mi", "tu", "este",
173
+ "esta", "estos", "estas",
169
174
  // pt
170
175
  "o", "os", "as", "um", "uma", "e", "ou", "para", "por", "com", "em", "que",
171
176
  "sao", "como", "quando", "sobre", "ao", "dos", "das", "no", "na", "se", "seu",
177
+ "onde", "quem", "preciso", "precisa", "ha", "meu", "minha", "este", "esta",
172
178
  ]);
173
179
  /**
174
180
  * Very light stemming: trims a common plural ending so a query term matches the
@@ -183,6 +189,20 @@ function stem(token) {
183
189
  return token.slice(0, -1);
184
190
  return token;
185
191
  }
192
+ /**
193
+ * One query parser for every MCP search surface.
194
+ *
195
+ * The core search already discarded function words, but Articles, Labs,
196
+ * claims and the Homeric Atlas each tokenised the raw question independently.
197
+ * In `search_all`, a Lab matching only "to" and "do" could therefore outrank
198
+ * the calculator named by the informative terms. Keeping the parser here
199
+ * makes the five surfaces agree on what a term is and prevents that drift.
200
+ */
201
+ export function queryTerms(query) {
202
+ const all = norm(query).split(/[^a-z0-9]+/).filter((token) => token.length >= 2);
203
+ const meaningful = all.filter((token) => !STOPWORDS.has(token));
204
+ return [...new Set((meaningful.length > 0 ? meaningful : all).map(stem))];
205
+ }
186
206
  /** Relative importance of each field when scoring a search hit. */
187
207
  /**
188
208
  * Cuánto puede sumar como mucho la centralidad. Por debajo del peso de un
@@ -457,8 +477,36 @@ function summarizeHomeric(kind, e, locale) {
457
477
  api_url: `${SITE_URL}/api/homeric/${kind}/${e.slug}`,
458
478
  };
459
479
  }
460
- export function makeContent(loadAll, loadHandbook, loadHomeric, loadClaims) {
461
- const getOne = (domain, slug) => loadAll(domain).find((e) => e.slug === slug);
480
+ export function makeContent(loadAll, loadHandbook, loadHomeric, loadClaims, loadSiteLabs) {
481
+ /**
482
+ * Una unidad se alcanza por su slug, por el identificador que ella misma
483
+ * declara (`ARCH-001`, `GOV-004`) y por los alias que arrastra del manual
484
+ * (`PAT-001`). El corpus publicaba esos identificadores —en el JSON, en el
485
+ * frontmatter y en la prosa de los capítulos— y después los rechazaba:
486
+ * `get_pattern("PAT-001")`, `get_governance("GOV-001")` y
487
+ * `get_architecture("ARCH-001")` devolvían `not_found` sobre unidades que
488
+ * existen. Es la misma inferencia por analogía que la analítica midió
489
+ * (`handbook/arch-001`, 15 veces): el agente no se lo inventaba, se lo
490
+ * habíamos anunciado nosotros.
491
+ *
492
+ * Sin distinguir mayúsculas, porque la prosa cita `PAT-001` y una URL trae
493
+ * `pat-001`, y el espacio de identificadores no debería depender de eso.
494
+ */
495
+ const getOne = (domain, slug) => {
496
+ const entries = loadAll(domain);
497
+ const exact = entries.find((e) => e.slug === slug);
498
+ if (exact)
499
+ return exact;
500
+ const needle = slug.toUpperCase();
501
+ return entries.find((e) => {
502
+ const r = e;
503
+ if (typeof r.id === "string" && r.id.toUpperCase() === needle)
504
+ return true;
505
+ if (Array.isArray(r.aliases))
506
+ return r.aliases.some((a) => typeof a === "string" && a.toUpperCase() === needle);
507
+ return false;
508
+ }) ?? entries.find((e) => e.slug.toUpperCase() === needle);
509
+ };
462
510
  /**
463
511
  * Enlaces entrantes por unidad — la única señal de centralidad que este
464
512
  * corpus ya tiene, y la que decide un empate de puntuación.
@@ -554,7 +602,7 @@ export function makeContent(loadAll, loadHandbook, loadHomeric, loadClaims) {
554
602
  {
555
603
  surface: "articles",
556
604
  description: "Federated first-party long-form essays, read from their canonical Articles API at call time.",
557
- tools: ["list_articles", "get_article", "search_articles"],
605
+ tools: [...SURFACE_TOOLS.articles],
558
606
  source: "https://articles.santismm.com/api/articles.json",
559
607
  lookup: "article slug",
560
608
  citation: "Each result carries canonical_url.",
@@ -562,11 +610,7 @@ export function makeContent(loadAll, loadHandbook, loadHomeric, loadClaims) {
562
610
  {
563
611
  surface: "homeric_atlas",
564
612
  description: "Places, episodes and rival route reconstructions using the atlas identification vocabulary and 0–12 rubric.",
565
- tools: [
566
- "list_homeric_places", "get_homeric_place",
567
- "list_homeric_episodes", "get_homeric_episode",
568
- "list_homeric_routes", "get_homeric_route",
569
- ],
613
+ tools: [...SURFACE_TOOLS.homeric_atlas],
570
614
  source: `${SITE_URL}/api/homeric-atlas.json`,
571
615
  lookup: "place, episode or route slug",
572
616
  citation: "Each content result carries canonical_url and api_url.",
@@ -574,7 +618,7 @@ export function makeContent(loadAll, loadHandbook, loadHomeric, loadClaims) {
574
618
  {
575
619
  surface: "claims",
576
620
  description: "The corpus's load-bearing claims with epistemic type, confidence, basis, limitations and falsification criteria.",
577
- tools: ["list_claims", "get_claim"],
621
+ tools: [...SURFACE_TOOLS.claims],
578
622
  source: "bundled claim registry",
579
623
  lookup: "claim id (for example HE-CLAIM-001) or slug",
580
624
  citation: "Claims have no public page; cite their stable id and the MCP endpoint.",
@@ -582,12 +626,7 @@ export function makeContent(loadAll, loadHandbook, loadHomeric, loadClaims) {
582
626
  {
583
627
  surface: "labs",
584
628
  description: "Interactive calculators, converters, experiments and educational games; three calculators execute deterministic, versioned formulas through the canonical Labs API.",
585
- tools: [
586
- "list_labs", "get_lab",
587
- "calculate_agent_economics",
588
- "calculate_evaluation_sample_size",
589
- "calculate_human_supervision_capacity",
590
- ],
629
+ tools: [...SURFACE_TOOLS.labs],
591
630
  source: "https://labs.santismm.com/api/labs",
592
631
  lookup: "Lab slug; executable tools take typed numeric assumptions",
593
632
  citation: "Definitions and calculation results carry canonical_url, api_url, version, assumptions and warnings.",
@@ -650,10 +689,7 @@ export function makeContent(loadAll, loadHandbook, loadHomeric, loadClaims) {
650
689
  search(query, domains = [...DOMAINS, "handbook"], limit = 20, locale = "en") {
651
690
  // norm() has already stripped diacritics, so a plain alphanumeric split
652
691
  // is enough (and avoids needing Unicode property escapes).
653
- const all = norm(query).split(/[^a-z0-9]+/).filter((t) => t.length >= 2);
654
- // Drop function words — unless that would leave nothing to search for.
655
- const meaningful = all.filter((t) => !STOPWORDS.has(t));
656
- const tokens = [...new Set((meaningful.length > 0 ? meaningful : all).map(stem))];
692
+ const tokens = queryTerms(query);
657
693
  if (tokens.length === 0)
658
694
  return [];
659
695
  // Collect the candidate documents first, so term specificity (IDF) can be
@@ -706,6 +742,13 @@ export function makeContent(loadAll, loadHandbook, loadHomeric, loadClaims) {
706
742
  scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
707
743
  return scored.slice(0, limit);
708
744
  },
745
+ /**
746
+ * El catálogo de labs del sitio. Vacío cuando nadie lo inyecta, igual que
747
+ * el manual o el Atlas: el proveedor decide qué superficies existen.
748
+ */
749
+ listSiteLabs() {
750
+ return loadSiteLabs ? loadSiteLabs() : [];
751
+ },
709
752
  /**
710
753
  * Claims (ADR 0003). The card carries the type and the confidence, because
711
754
  * that is the whole point: an agent must be able to tell an observed fact
@@ -0,0 +1,65 @@
1
+ /**
2
+ * A qué superficie pertenece cada herramienta — declarado una vez.
3
+ *
4
+ * `get_overview` ya enumeraba las superficies de extensión con sus
5
+ * herramientas, y la analítica contaba por herramienta y nada más. Cruzar las
6
+ * dos cosas exigía una tabla escrita a mano, que es la clase de cifra que
7
+ * acaba estando mal: cada herramienta nueva la habría dejado incompleta en
8
+ * silencio, y una superficie sin datos se lee como «nadie la usa».
9
+ *
10
+ * Aquí no sobra nada por descarte: el núcleo se declara igual que las
11
+ * extensiones. `meta` son las dos transversales —`get_overview` y
12
+ * `search_all`— y separarlas no es cosmético: la lectura de 90 días encontró
13
+ * que ~16.400 de 39.499 peticiones eran saludo y catálogo, así que mezclarlas
14
+ * con el consumo de contenido es justo lo que hace que el volumen parezca
15
+ * tracción.
16
+ *
17
+ * `SURFACE_TOOLS` es la fuente; `get_overview` sirve las extensiones desde
18
+ * ella y la analítica deriva su vocabulario de la misma constante. Una
19
+ * herramienta que no aparezca aquí pone rojo `npm run validate`.
20
+ */
21
+ export const SURFACE_TOOLS = {
22
+ core: [
23
+ "list_knowledge", "get_knowledge",
24
+ "list_patterns", "get_pattern",
25
+ "list_architectures", "get_architecture",
26
+ "list_governance", "get_governance",
27
+ "list_handbook", "get_handbook",
28
+ "get_related", "search",
29
+ ],
30
+ meta: ["get_overview", "search_all"],
31
+ articles: ["list_articles", "get_article", "search_articles"],
32
+ homeric_atlas: [
33
+ "list_homeric_places", "get_homeric_place",
34
+ "list_homeric_episodes", "get_homeric_episode",
35
+ "list_homeric_routes", "get_homeric_route",
36
+ ],
37
+ claims: ["list_claims", "get_claim"],
38
+ labs: [
39
+ "list_labs", "get_lab",
40
+ "calculate_agent_economics",
41
+ "calculate_evaluation_sample_size",
42
+ "calculate_human_supervision_capacity",
43
+ ],
44
+ };
45
+ /** Las superficies, en orden estable. */
46
+ export const SURFACES = Object.keys(SURFACE_TOOLS);
47
+ /**
48
+ * Surfaces queried by the federated search, shared by its schemas, handler and
49
+ * final-outcome observer. Keeping this beside the import-free surface registry
50
+ * avoids a tools.ts <-> outcomes.ts cycle and makes a search-surface change
51
+ * atomic for both execution and measurement.
52
+ */
53
+ export const SEARCH_SURFACES = ["core", "articles", "labs", "claims", "homeric_atlas"];
54
+ /**
55
+ * Las tres primitivas del protocolo. Hoy todo se expresa con `tool`, y por eso
56
+ * el eje se registra desde el principio: si Resources y Prompts (REG-19,
57
+ * REG-20) llegaran a una analítica que solo cuenta herramientas, dos semanas
58
+ * de datos dirían que nadie los usa cuando lo que pasa es que nadie los mide.
59
+ */
60
+ export const PRIMITIVES = ["tool", "resource", "prompt"];
61
+ const BY_TOOL = new Map(SURFACES.flatMap((s) => SURFACE_TOOLS[s].map((t) => [t, s])));
62
+ /** La superficie de una herramienta, o `undefined` si no está declarada. */
63
+ export function surfaceOf(tool) {
64
+ return BY_TOOL.get(tool);
65
+ }
package/dist/tools.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { z } from "zod";
2
+ import { norm, queryTerms } from "./shape.js";
2
3
  import { ARTICLES_API_URL, articleCard, articlesForLocale, loadArticles, searchArticleCorpus, } from "./articles.js";
3
4
  import { LABS_API_URL, executeLabCalculator, loadLabs, searchLabCorpus, } from "./labs.js";
5
+ import { lookupRequestForTool, summarizeToolResult, } from "./outcomes.js";
6
+ import { SEARCH_SURFACES } from "./surfaces.js";
4
7
  /**
5
8
  * Single, framework-agnostic definition of the Santismm Knowledge MCP server:
6
9
  * its identity and the tool registry. Both transports — the stdio CLI
@@ -10,7 +13,7 @@ import { LABS_API_URL, executeLabCalculator, loadLabs, searchLabCorpus, } from "
10
13
  * `McpContent` provider); both providers read the same canonical repository
11
14
  * data, while Article tools deliberately read the first-party Articles API.
12
15
  */
13
- export const SERVER_INFO = { name: "santismm-knowledge", version: "0.4.0" };
16
+ export const SERVER_INFO = { name: "santismm-knowledge", version: "0.4.1" };
14
17
  /**
15
18
  * Core tools read a static local corpus, so all four hints are literally true:
16
19
  * nothing mutates, the same arguments produce the same answer, and no core
@@ -293,6 +296,77 @@ function distancia(a, b, tope) {
293
296
  function normalizar(s) {
294
297
  return s.toLowerCase().replace(/[\s_]+/g, "-");
295
298
  }
299
+ function urlSlug(value) {
300
+ if (typeof value !== "string")
301
+ return undefined;
302
+ try {
303
+ return new URL(value).pathname.split("/").filter(Boolean).at(-1);
304
+ }
305
+ catch {
306
+ return undefined;
307
+ }
308
+ }
309
+ /**
310
+ * Enrich a direct lookup miss without delaying the tool response itself.
311
+ *
312
+ * Local recovery payloads already carry `found_in`. Federated Articles and
313
+ * Labs cannot be inspected synchronously by `noEncontrado`, so the hosted
314
+ * analytics callback runs this after the response and checks their cached
315
+ * canonical catalogues. A Portuguese Article slug sent to
316
+ * `get_architecture`, for example, is a wrong-tool event, not a content gap.
317
+ */
318
+ export async function observeToolResult(event, content) {
319
+ const observation = summarizeToolResult(event);
320
+ if (observation.demandClass !== "invalid_identifier")
321
+ return observation;
322
+ const lookup = lookupRequestForTool(event.tool, event.args);
323
+ if (!lookup)
324
+ return observation;
325
+ const wanted = normalizar(lookup.identifier);
326
+ for (const space of ESPACIOS) {
327
+ if (space.domain === lookup.domain || space.domain === "articles" || space.domain === "labs")
328
+ continue;
329
+ try {
330
+ const exact = cardsDe(content, space.domain, "en").find((candidate) => {
331
+ const item = candidate;
332
+ return [item?.id, item?.slug].some((identifier) => typeof identifier === "string" && normalizar(identifier) === wanted);
333
+ });
334
+ const id = exact && (typeof exact.slug === "string" ? exact.slug : exact.id);
335
+ if (typeof id === "string") {
336
+ return {
337
+ ...observation,
338
+ demandClass: "wrong_tool",
339
+ foundIn: { domain: space.domain, id, tool: space.getTool },
340
+ };
341
+ }
342
+ }
343
+ catch {
344
+ // One optional recovery surface cannot hide the original observation.
345
+ }
346
+ }
347
+ const [articleLoad, labLoad] = await Promise.allSettled([loadArticles(), loadLabs()]);
348
+ if (lookup.domain !== "articles" && articleLoad.status === "fulfilled") {
349
+ const article = articleLoad.value.find((candidate) => [candidate.slug, urlSlug(candidate.canonical_url), urlSlug(candidate.api_url)].some((identifier) => typeof identifier === "string" && normalizar(identifier) === wanted));
350
+ if (article) {
351
+ return {
352
+ ...observation,
353
+ demandClass: "wrong_tool",
354
+ foundIn: { domain: "articles", id: article.slug, tool: "get_article" },
355
+ };
356
+ }
357
+ }
358
+ if (lookup.domain !== "labs" && labLoad.status === "fulfilled") {
359
+ const lab = labLoad.value.find((candidate) => normalizar(candidate.slug) === wanted);
360
+ if (lab) {
361
+ return {
362
+ ...observation,
363
+ demandClass: "wrong_tool",
364
+ foundIn: { domain: "labs", id: lab.slug, tool: "get_lab" },
365
+ };
366
+ }
367
+ }
368
+ return observation;
369
+ }
296
370
  /** Most identifiers a not-found payload will spell out before summarising. */
297
371
  const MUESTRA_MAXIMA = 30;
298
372
  /**
@@ -481,7 +555,22 @@ const relatedContentSchema = z.object({
481
555
  url: z.string(),
482
556
  relationship: z.string(),
483
557
  });
484
- const labCardSchema = z.object({
558
+ /**
559
+ * Un lab es una de dos cosas y el contrato lo dice en vez de difuminarlo.
560
+ *
561
+ * Las diez que ejecuta el servicio federado traen fórmulas, entradas, salidas
562
+ * y supuestos: eso es lo que hace verificable un cálculo y no se afloja a
563
+ * opcional para que quepan las otras. Las once del ápice son páginas que se
564
+ * leen —el Atlas Homérico, el sandbox, la taxonomía, los benchmarks, los
565
+ * atlas deportivos— y declararles `inputs: []` sería mentir con la forma.
566
+ *
567
+ * `executable` es el discriminante, y responde la única pregunta que un
568
+ * agente necesita antes de decidir qué hacer con la unidad: ¿esto se calcula
569
+ * o esto se lee?
570
+ */
571
+ const executableLabSchema = z.object({
572
+ executable: z.literal(true),
573
+ owner_server: z.string().describe("Host that serves and versions this unit."),
485
574
  slug: z.string(),
486
575
  kind: z.enum(["calculator", "converter", "experiment", "educational-game"]),
487
576
  label: z.string(),
@@ -498,6 +587,17 @@ const labCardSchema = z.object({
498
587
  calculation_url: z.string().optional(),
499
588
  related_content: z.array(relatedContentSchema).optional(),
500
589
  });
590
+ const pageLabSchema = z.object({
591
+ executable: z.literal(false),
592
+ owner_server: z.string().describe("Host that serves this unit."),
593
+ slug: z.string(),
594
+ category: z.string().describe("benchmarks, simulators, experiments, games…"),
595
+ status: z.string(),
596
+ title: z.string(),
597
+ summary: z.string().optional(),
598
+ canonical_url: z.string().describe("Cite this URL."),
599
+ });
600
+ const labCardSchema = z.discriminatedUnion("executable", [executableLabSchema, pageLabSchema]);
501
601
  const labListOutput = { count: z.number(), results: z.array(labCardSchema) };
502
602
  const calculationBaseSchema = z.object({
503
603
  schema_version: z.string(),
@@ -537,7 +637,7 @@ const humanSupervisionOutput = calculationBaseSchema.extend({
537
637
  }),
538
638
  });
539
639
  const globalSearchCard = z.object({
540
- surface: z.enum(["core", "articles", "labs", "claims"]),
640
+ surface: z.enum(SEARCH_SURFACES),
541
641
  score: z.number(),
542
642
  source_score: z.number(),
543
643
  rank_within_surface: z.number(),
@@ -623,47 +723,147 @@ function labNotFound(labs, slug) {
623
723
  return { content: [{ type: "text", text: JSON.stringify(body, null, 2) }], isError: true };
624
724
  }
625
725
  function termsFor(query) {
626
- return [...new Set(query.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().split(/[^a-z0-9]+/).filter((term) => term.length > 1))];
726
+ return queryTerms(query);
727
+ }
728
+ /**
729
+ * Puntuación por campos con peso, compartida por las superficies que no
730
+ * tienen scorer propio. Este corpus ya arrastra cuatro implementaciones del
731
+ * ranking (núcleo, artículos, labs y claims) y la lección de REG-12 vale
732
+ * igual aquí: dos implementaciones del scoring divergirían. La homérica no
733
+ * añade una quinta — reutiliza ésta.
734
+ */
735
+ function fieldScore(record, fields, terms) {
736
+ let score = 0;
737
+ const matchedFields = new Set();
738
+ const matchedTerms = new Set();
739
+ for (const [field, weight] of fields) {
740
+ const value = norm(String(record[field] ?? ""));
741
+ for (const term of terms) {
742
+ if (!value.includes(term))
743
+ continue;
744
+ score += weight;
745
+ matchedFields.add(field);
746
+ matchedTerms.add(term);
747
+ }
748
+ }
749
+ const coverage = terms.length > 0 ? matchedTerms.size / terms.length : 0;
750
+ return {
751
+ score: Math.round(score * coverage * coverage * 100) / 100,
752
+ matchedFields: [...matchedFields],
753
+ matchedTerms: [...matchedTerms],
754
+ };
627
755
  }
628
756
  function claimSearch(content, query, locale, limit) {
629
757
  const terms = termsFor(query);
758
+ const fields = [["statement", 7], ["slug", 6], ["id", 5], ["claim_type", 4]];
630
759
  return content.listClaims(undefined, locale)
631
- .map((claim) => {
632
- const fields = [["statement", 7], ["slug", 6], ["id", 5], ["claim_type", 4]];
633
- let score = 0;
634
- const matchedFields = new Set();
635
- const matchedTerms = new Set();
636
- for (const [field, weight] of fields) {
637
- const value = String(claim[field] ?? "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
638
- for (const term of terms) {
639
- if (!value.includes(term))
640
- continue;
641
- score += weight;
642
- matchedFields.add(field);
643
- matchedTerms.add(term);
644
- }
645
- }
646
- return { claim, score, matchedFields: [...matchedFields], matchedTerms: [...matchedTerms] };
647
- })
760
+ .map((claim) => ({ claim, ...fieldScore(claim, fields, terms) }))
648
761
  .filter((hit) => hit.score > 0)
649
762
  .sort((a, b) => b.score - a.score || String(a.claim.id).localeCompare(String(b.claim.id)))
650
763
  .slice(0, limit);
651
764
  }
765
+ /**
766
+ * El Atlas Homérico tiene seis herramientas propias desde hace semanas, así
767
+ * que su contenido SÍ es alcanzable — pero `search_all` declaraba cuatro
768
+ * superficies y ninguna era la suya. Quien preguntase por Ítaca en la
769
+ * búsqueda global no encontraba nada y no tenía por qué saber que existe un
770
+ * `list_homeric_places` al que llamar. Es el hueco que REG-17 nombra: no
771
+ * falta contenido, falta que el catálogo represente la superficie entera.
772
+ */
773
+ const HOMERIC_TOOL = {
774
+ places: "get_homeric_place",
775
+ episodes: "get_homeric_episode",
776
+ routes: "get_homeric_route",
777
+ };
778
+ function homericSearch(content, query, locale, limit) {
779
+ const terms = termsFor(query);
780
+ const fields = [
781
+ ["name", 8], ["slug", 7], ["summary", 6], ["identification", 5],
782
+ ["region", 4], ["work", 3], ["citation", 2], ["kind", 1],
783
+ ];
784
+ const hits = [];
785
+ for (const kind of ["places", "episodes", "routes"])
786
+ for (const raw of content.listHomeric(kind, locale)) {
787
+ const scored = fieldScore(raw, fields, terms);
788
+ // A natural question often adds generic qualifiers ("how strong is the
789
+ // evidence?") after naming the entity. Those words may occur together
790
+ // in another entry's prose; an exact slug/name still has to win because
791
+ // it is the object the caller explicitly named.
792
+ const identifiers = [raw.slug, raw.name, raw.id]
793
+ .map((value) => norm(String(value ?? "")))
794
+ .filter(Boolean);
795
+ const exactIdentifier = identifiers.some((identifier) => identifier.split(" ").every((part) => terms.includes(part)));
796
+ const score = scored.score + (exactIdentifier ? 12 : 0);
797
+ if (score > 0)
798
+ hits.push({ ...raw, ...scored, score, kind });
799
+ }
800
+ return hits
801
+ .sort((a, b) => b.score - a.score || String(a.slug).localeCompare(String(b.slug)))
802
+ .slice(0, limit);
803
+ }
652
804
  const GET_TOOL_FOR_DOMAIN = {
653
805
  knowledge: "get_knowledge", patterns: "get_pattern", architectures: "get_architecture",
654
806
  governance: "get_governance", handbook: "get_handbook",
655
807
  };
656
808
  function intentBoost(query, surface) {
657
- const normal = termsFor(query).join(" ");
809
+ // Intent words such as "how many" are useful for routing even though they
810
+ // are deliberately removed from lexical relevance scoring.
811
+ const normal = norm(query);
658
812
  if (surface === "labs" && /\b(calcul\w*|how many|cuant\w*|sample|muestra|roi|cost\w*|coste\w*|supervis\w*|fte|capacity|capacidad|break even|token\w*|pages|paginas)\b/.test(normal))
659
813
  return 30;
660
814
  if (surface === "claims" && /\b(claim|claims|evidence|fact|thesis|tesis|hypothesis|hipotesis|falsif|refut)\b/.test(normal))
661
815
  return 25;
816
+ if (surface === "homeric_atlas" && /\b(homer\w*|homér\w*|iliad\w*|ilíad\w*|odyssey|odisea|odisseia|ithaca|itaca|ítaca|troy|troya|ulysses|ulises|odysseus|odiseo)\b/.test(normal))
817
+ return 25;
662
818
  if (surface === "articles" && /\b(article|articles|essay|essays|articulo|artículo|ensayo|recent|latest|nuevo|reciente)\b/.test(normal))
663
819
  return 20;
664
820
  return 0;
665
821
  }
666
- export function registerTools(server, content) {
822
+ /**
823
+ * Wrap the registry once, rather than remembering telemetry in 30 handlers.
824
+ *
825
+ * The callback receives the handler's final value and runs only when a hosted
826
+ * transport injects it. The stdio package passes no telemetry and remains a
827
+ * local, analytics-free server. Telemetry is best-effort: observing a result
828
+ * can never turn that result into an error for the caller.
829
+ */
830
+ function withToolResultTelemetry(server, telemetry) {
831
+ if (!telemetry?.toolResult)
832
+ return server;
833
+ return {
834
+ registerTool(name, config, handler) {
835
+ return server.registerTool(name, config, async (rawArgs) => {
836
+ const args = rawArgs && typeof rawArgs === "object"
837
+ ? rawArgs
838
+ : {};
839
+ try {
840
+ const result = await handler(rawArgs);
841
+ try {
842
+ telemetry.toolResult?.({ tool: name, args, result });
843
+ }
844
+ catch {
845
+ // Analytics can never break a tool response.
846
+ }
847
+ return result;
848
+ }
849
+ catch (error) {
850
+ try {
851
+ telemetry.toolResult?.({ tool: name, args, error });
852
+ }
853
+ catch {
854
+ // Preserve the original tool error.
855
+ }
856
+ throw error;
857
+ }
858
+ });
859
+ },
860
+ };
861
+ }
862
+ export function registerTools(server, content, telemetry) {
863
+ // Reassign the structural adapter so the existing registry remains the
864
+ // single list every validator derives from. Every registerTool below now
865
+ // gains the same final-result boundary without 30 hand-written callbacks.
866
+ server = withToolResultTelemetry(server, telemetry);
667
867
  // ── Orientation ────────────────────────────────────────────────────────────
668
868
  server.registerTool("get_overview", {
669
869
  title: "Corpus Overview — Start Here",
@@ -709,18 +909,18 @@ export function registerTools(server, content) {
709
909
  server.registerTool("search_all", {
710
910
  title: "Search every SANTISMM knowledge surface",
711
911
  annotations: READ_ONLY_REMOTE,
712
- description: "Search the core corpus, first-party essays, executable Labs and epistemic claims in one call. Use this first when a natural-language question might require a calculation, a long-form essay or a claim audit rather than only a core knowledge unit. Results name the next tool to call; calculator-shaped questions are routed toward Labs.",
912
+ description: "Search the core corpus, first-party essays, executable Labs, epistemic claims and the Homeric Atlas in one call. Use this first when a natural-language question might require a calculation, a long-form essay or a claim audit rather than only a core knowledge unit. Results name the next tool to call; calculator-shaped questions are routed toward Labs.",
713
913
  inputSchema: z.object({
714
914
  query: querySchema.describe("Question or topic, in English, Spanish or Portuguese."),
715
- surfaces: z.array(z.enum(["core", "articles", "labs", "claims"])).min(1).optional()
716
- .describe("Restrict the search. Omit to search all four surfaces."),
915
+ surfaces: z.array(z.enum(SEARCH_SURFACES)).min(1).optional()
916
+ .describe("Restrict the search. Omit to search all five surfaces."),
717
917
  limit_per_surface: z.number().int().positive().max(10).optional().describe("Maximum hits from each surface. Default: 5."),
718
918
  locale: localeSchema,
719
919
  }),
720
920
  outputSchema: z.object(globalSearchOutput),
721
921
  }, async ({ query, surfaces, limit_per_surface, locale }) => {
722
922
  try {
723
- const selected = new Set(surfaces ?? ["core", "articles", "labs", "claims"]);
923
+ const selected = new Set(surfaces ?? SEARCH_SURFACES);
724
924
  const limit = limit_per_surface ?? 5;
725
925
  const lang = (locale ?? "en");
726
926
  const [articleLoad, labLoad] = await Promise.allSettled([
@@ -804,6 +1004,19 @@ export function registerTools(server, content) {
804
1004
  });
805
1005
  }
806
1006
  }
1007
+ if (selected.has("homeric_atlas")) {
1008
+ const boost = intentBoost(query, "homeric_atlas");
1009
+ for (const [index, raw] of homericSearch(content, query, lang, limit).entries()) {
1010
+ hits.push({
1011
+ surface: "homeric_atlas", score: raw.score + boost, source_score: raw.score,
1012
+ rank_within_surface: index + 1, slug: raw.slug, kind: raw.kind,
1013
+ title: String(raw.name ?? raw.slug), summary: raw.summary,
1014
+ canonical_url: raw.canonical_url, api_url: raw.api_url,
1015
+ suggested_tool: HOMERIC_TOOL[raw.kind],
1016
+ matchedFields: raw.matchedFields, matchedTerms: raw.matchedTerms,
1017
+ });
1018
+ }
1019
+ }
807
1020
  hits.sort((a, b) => Number(b.score) - Number(a.score) || Number(a.rank_within_surface) - Number(b.rank_within_surface));
808
1021
  return out({ query, count: hits.length, results: hits, unavailable_surfaces: unavailableSurfaces }, hits);
809
1022
  }
@@ -986,6 +1199,44 @@ export function registerTools(server, content) {
986
1199
  return articleFailure(error);
987
1200
  }
988
1201
  });
1202
+ /**
1203
+ * Las 21 unidades que el sitio llama lab, en un solo listado.
1204
+ *
1205
+ * Eran dos catálogos con el mismo nombre: `labs.santismm.com/api/labs` sirve
1206
+ * las diez que ejecuta —con fórmulas, entradas, salidas y endpoint de cálculo—
1207
+ * y el ápice publica esas diez más once páginas propias: el Atlas Homérico, el
1208
+ * sandbox de control, la taxonomía, los benchmarks y los tres atlas
1209
+ * deportivos. `ai-index.json` anunciaba `count: 21` y `list_labs` devolvía 10,
1210
+ * así que un agente que leyera el índice y llamara a la herramienta no podía
1211
+ * alcanzar once de ellas por ninguna vía.
1212
+ *
1213
+ * La definición ejecutable gana cuando existe: trae todo lo que trae la
1214
+ * entrada del catálogo y además las fórmulas. `owner_server` dice quién sirve
1215
+ * cada unidad, que es lo que distingue «esto se calcula» de «esto se lee».
1216
+ *
1217
+ * Deliberadamente sin `resource_uri`: los Resources de MCP no existen todavía
1218
+ * (REG-19), y anunciar un identificador que no resuelve es el fallo que SEG-06
1219
+ * documentó — cuesta más que una función que falta.
1220
+ */
1221
+ const LABS_HOST = new URL(LABS_API_URL).host;
1222
+ async function mergedLabs(content, kind) {
1223
+ const ejecutables = await loadLabs();
1224
+ const porSlug = new Map();
1225
+ for (const entrada of content.listSiteLabs())
1226
+ porSlug.set(String(entrada.slug), { ...entrada, executable: false });
1227
+ for (const lab of ejecutables) {
1228
+ // Sin heredar la entrada del catálogo: `category`/`status`/`summary`
1229
+ // dirían con otras palabras lo que `kind`/`description` ya dicen, y dos
1230
+ // vocabularios para un concepto es justo lo que SEG-07 limpió.
1231
+ porSlug.set(lab.slug, {
1232
+ ...lab,
1233
+ owner_server: LABS_HOST,
1234
+ executable: true,
1235
+ });
1236
+ }
1237
+ const todos = [...porSlug.values()];
1238
+ return kind ? todos.filter((lab) => lab.kind === kind) : todos;
1239
+ }
989
1240
  // ── SANTISMM Labs (federated metadata + deterministic execution) ─────────
990
1241
  server.registerTool("list_labs", {
991
1242
  title: "List calculators, converters, experiments and educational Labs",
@@ -997,8 +1248,7 @@ export function registerTools(server, content) {
997
1248
  outputSchema: z.object(labListOutput),
998
1249
  }, async ({ kind }) => {
999
1250
  try {
1000
- const labs = await loadLabs();
1001
- return outList(kind ? labs.filter((lab) => lab.kind === kind) : labs);
1251
+ return outList(await mergedLabs(content, kind));
1002
1252
  }
1003
1253
  catch (error) {
1004
1254
  return labsFailure(error);
@@ -1012,9 +1262,11 @@ export function registerTools(server, content) {
1012
1262
  outputSchema: labCardSchema,
1013
1263
  }, async ({ slug }) => {
1014
1264
  try {
1015
- const labs = await loadLabs();
1265
+ const labs = await mergedLabs(content);
1016
1266
  const lab = labs.find((candidate) => candidate.slug === slug);
1017
- return lab ? out(lab) : labNotFound(labs, slug);
1267
+ return lab
1268
+ ? out(lab)
1269
+ : labNotFound(labs, slug);
1018
1270
  }
1019
1271
  catch (error) {
1020
1272
  return labsFailure(error);
@@ -1038,11 +1290,12 @@ export function registerTools(server, content) {
1038
1290
  reviewRate: z.number().min(0).max(100).describe("Share of cases reviewed by a person."),
1039
1291
  reviewMinutes: z.number().min(0).max(10_080).describe("Human review minutes per reviewed case."),
1040
1292
  reworkMinutes: z.number().min(0).max(10_080).describe("Human rework minutes per failed case."),
1293
+ locale: localeSchema.describe("Language for interpretations, assumptions, formulas and warnings (default en)."),
1041
1294
  }),
1042
1295
  outputSchema: agentEconomicsOutput,
1043
- }, async (inputs) => {
1296
+ }, async ({ locale, ...inputs }) => {
1044
1297
  try {
1045
- return out(await executeLabCalculator("agent-economics", inputs));
1298
+ return out(await executeLabCalculator("agent-economics", inputs, (locale ?? "en")));
1046
1299
  }
1047
1300
  catch (error) {
1048
1301
  return labsFailure(error);
@@ -1057,11 +1310,12 @@ export function registerTools(server, content) {
1057
1310
  confidence: z.union([z.literal(90), z.literal(95), z.literal(99)]).describe("Confidence level, in percent."),
1058
1311
  margin: z.number().min(0.1).max(50).describe("Margin for estimating the failure rate, in percentage points."),
1059
1312
  population: z.number().min(1).max(1_000_000_000).describe("Number of distinct evaluable cases."),
1313
+ locale: localeSchema.describe("Language for interpretations, assumptions, formulas and warnings (default en)."),
1060
1314
  }),
1061
1315
  outputSchema: evaluationSampleOutput,
1062
- }, async (inputs) => {
1316
+ }, async ({ locale, ...inputs }) => {
1063
1317
  try {
1064
- return out(await executeLabCalculator("evaluation-sample-size", inputs));
1318
+ return out(await executeLabCalculator("evaluation-sample-size", inputs, (locale ?? "en")));
1065
1319
  }
1066
1320
  catch (error) {
1067
1321
  return labsFailure(error);
@@ -1082,11 +1336,12 @@ export function registerTools(server, content) {
1082
1336
  utilization: z.number().min(1).max(100).describe("Share of paid time available for review and escalation, in percent."),
1083
1337
  reviewers: z.number().min(0.1).max(1_000_000).describe("Available reviewer FTE."),
1084
1338
  hourlyCost: z.number().min(0).max(1_000_000).describe("Fully loaded reviewer hourly cost, in the chosen currency."),
1339
+ locale: localeSchema.describe("Language for interpretations, assumptions, formulas and warnings (default en)."),
1085
1340
  }),
1086
1341
  outputSchema: humanSupervisionOutput,
1087
- }, async (inputs) => {
1342
+ }, async ({ locale, ...inputs }) => {
1088
1343
  try {
1089
- return out(await executeLabCalculator("human-supervision-capacity", inputs));
1344
+ return out(await executeLabCalculator("human-supervision-capacity", inputs, (locale ?? "en")));
1090
1345
  }
1091
1346
  catch (error) {
1092
1347
  return labsFailure(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santismm-knowledge-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "license": "MIT",
5
5
  "description": "MCP server for the Santismm Knowledge Platform — core knowledge, first-party essays, Homeric Atlas datasets and epistemic claims. Ships the core corpus; the hosted endpoint at https://santismm.com/mcp is the always-fresh alternative.",
6
6
  "type": "module",