santismm-knowledge-mcp 0.3.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
@@ -2,8 +2,8 @@
2
2
 
3
3
  [![santismm-knowledge-mcp MCP server](https://glama.ai/mcp/servers/santismm/santismm-knowledge-mcp/badges/card.svg)](https://glama.ai/mcp/servers/santismm/santismm-knowledge-mcp)
4
4
 
5
- > MCP server for the Santismm Knowledge Platform — harness engineering, agentic
6
- > AI patterns, reference architectures, AI governance and the agent taxonomy.
5
+ > MCP server for the Santismm Knowledge Platform — five core knowledge domains,
6
+ > first-party essays, Homeric Atlas datasets and epistemic claims.
7
7
 
8
8
  **This repository is generated** from the platform at [santismm.com](https://santismm.com).
9
9
  Do not edit it by hand; changes are overwritten on the next sync. Corrections go
@@ -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
@@ -68,7 +82,7 @@ is not a software licence and MIT is not a content licence.
68
82
 
69
83
  ## What it exposes
70
84
 
71
- 24 read-only tools over knowledge, patterns, architectures, governance, the
85
+ 30 read-only tools over knowledge, patterns, architectures, governance, the
72
86
  Harness Engineering Handbook and first-party Articles, each declaring an
73
87
  `outputSchema` and returning validated `structuredContent`. Every tool is
74
88
  annotated `readOnlyHint: true`, `destructiveHint: false` and
@@ -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.3.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 ADDED
@@ -0,0 +1,138 @@
1
+ import { norm, queryTerms } from "./shape.js";
2
+ /**
3
+ * Federated SANTISMM Labs catalogue and deterministic calculators.
4
+ *
5
+ * Labs owns the formulas and executes them. The knowledge MCP deliberately
6
+ * proxies the canonical API instead of copying arithmetic into this package:
7
+ * one formula version serves the interactive UI, REST callers and MCP agents.
8
+ */
9
+ export const LABS_API_URL = process.env.SANTISMM_LABS_API_URL ?? 'https://labs.santismm.com/api/labs';
10
+ const LABS_SERVICE_ORIGIN = new URL(LABS_API_URL).origin;
11
+ const LABS_CANONICAL_ORIGIN = 'https://labs.santismm.com';
12
+ const CACHE_TTL_MS = 5 * 60 * 1000;
13
+ let cache;
14
+ let pending;
15
+ function strings(value) {
16
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
17
+ }
18
+ function validLab(value) {
19
+ const lab = value;
20
+ return Boolean(lab &&
21
+ typeof lab.slug === 'string' &&
22
+ ['calculator', 'converter', 'experiment', 'educational-game'].includes(String(lab.kind)) &&
23
+ typeof lab.label === 'string' &&
24
+ typeof lab.title === 'string' &&
25
+ typeof lab.description === 'string' &&
26
+ strings(lab.inputs) &&
27
+ strings(lab.outputs) &&
28
+ (lab.formulas === undefined || strings(lab.formulas)) &&
29
+ strings(lab.assumptions) &&
30
+ typeof lab.version === 'string' &&
31
+ typeof lab.updated === 'string' &&
32
+ typeof lab.canonical_url === 'string' &&
33
+ lab.canonical_url.startsWith(`${LABS_CANONICAL_ORIGIN}/`) &&
34
+ typeof lab.api_url === 'string' &&
35
+ lab.api_url.startsWith(`${LABS_CANONICAL_ORIGIN}/api/labs/`) &&
36
+ (lab.calculation_url === undefined || lab.calculation_url.startsWith(`${LABS_CANONICAL_ORIGIN}/api/calculate/`)));
37
+ }
38
+ async function fetchCorpus() {
39
+ const response = await fetch(LABS_API_URL, {
40
+ headers: { Accept: 'application/json', 'User-Agent': 'santismm-knowledge-mcp/0.4.1' },
41
+ signal: AbortSignal.timeout(8_000),
42
+ cache: 'no-store',
43
+ });
44
+ if (!response.ok)
45
+ throw new Error(`Labs API returned HTTP ${response.status}`);
46
+ const raw = await response.json();
47
+ if (raw.source !== 'SANTISMM Labs' ||
48
+ typeof raw.canonical_url !== 'string' ||
49
+ !Array.isArray(raw.results) ||
50
+ !raw.results.every(validLab) ||
51
+ raw.count !== raw.results.length) {
52
+ throw new Error('Labs API returned an invalid catalogue contract');
53
+ }
54
+ return raw;
55
+ }
56
+ export async function loadLabs() {
57
+ if (cache && cache.expiresAt > Date.now())
58
+ return cache.corpus.results;
59
+ if (!pending) {
60
+ pending = fetchCorpus()
61
+ .then((corpus) => {
62
+ cache = { expiresAt: Date.now() + CACHE_TTL_MS, corpus };
63
+ return corpus;
64
+ })
65
+ .finally(() => {
66
+ pending = undefined;
67
+ });
68
+ }
69
+ return (await pending).results;
70
+ }
71
+ export function searchLabCorpus(labs, query, limit) {
72
+ const terms = queryTerms(query);
73
+ const phrase = norm(query).trim();
74
+ if (terms.length === 0)
75
+ return [];
76
+ const fields = [
77
+ ['title', 8], ['slug', 7], ['description', 6], ['inputs', 4], ['outputs', 4],
78
+ ['formulas', 3], ['assumptions', 2], ['kind', 1],
79
+ ];
80
+ return labs
81
+ .map((lab) => {
82
+ const matchedFields = new Set();
83
+ const matchedTerms = new Set();
84
+ let score = 0;
85
+ for (const [field, weight] of fields) {
86
+ const raw = lab[field];
87
+ const value = norm(Array.isArray(raw) ? raw.join(' ') : String(raw ?? ''));
88
+ for (const term of terms) {
89
+ if (!value.includes(term))
90
+ continue;
91
+ score += weight;
92
+ matchedFields.add(field);
93
+ matchedTerms.add(term);
94
+ }
95
+ if (phrase.length > 2 && value.includes(phrase))
96
+ score += weight * 2;
97
+ }
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
+ };
105
+ })
106
+ .filter((lab) => lab.score > 0)
107
+ .sort((a, b) => b.score - a.score || b.updated.localeCompare(a.updated) || a.slug.localeCompare(b.slug))
108
+ .slice(0, limit);
109
+ }
110
+ export async function executeLabCalculator(slug, inputs, locale = 'en') {
111
+ const response = await fetch(`${LABS_SERVICE_ORIGIN}/api/calculate/${slug}?locale=${locale}`, {
112
+ method: 'POST',
113
+ headers: {
114
+ Accept: 'application/json',
115
+ 'Content-Type': 'application/json',
116
+ 'User-Agent': 'santismm-knowledge-mcp/0.4.1',
117
+ },
118
+ body: JSON.stringify(inputs),
119
+ signal: AbortSignal.timeout(8_000),
120
+ cache: 'no-store',
121
+ });
122
+ const raw = await response.json();
123
+ if (!response.ok) {
124
+ const message = typeof raw.error === 'string' ? raw.error : `HTTP ${response.status}`;
125
+ throw new Error(`Labs calculator ${slug} failed: ${message}`);
126
+ }
127
+ if (raw.slug !== slug ||
128
+ typeof raw.version !== 'string' ||
129
+ typeof raw.canonical_url !== 'string' ||
130
+ typeof raw.api_url !== 'string' ||
131
+ typeof raw.inputs !== 'object' ||
132
+ typeof raw.results !== 'object' ||
133
+ !Array.isArray(raw.assumptions) ||
134
+ !Array.isArray(raw.warnings)) {
135
+ throw new Error(`Labs calculator ${slug} returned an invalid result contract`);
136
+ }
137
+ return raw;
138
+ }
@@ -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
+ }