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 +14 -0
- package/content/labs/catalogue.json +205 -0
- package/content/library/devready-ep108-ai-customer-experiences.md +0 -1
- package/content/patterns/reflection.json +1 -0
- package/dist/articles.js +7 -11
- package/dist/content.js +16 -1
- package/dist/labs.js +15 -11
- package/dist/outcomes.js +163 -0
- package/dist/shape.js +64 -21
- package/dist/surfaces.js +65 -0
- package/dist/tools.js +292 -37
- package/package.json +1 -1
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
|
---
|
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.
|
|
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 =
|
|
83
|
-
const phrase =
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
75
|
-
const phrase =
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
116
|
+
'User-Agent': 'santismm-knowledge-mcp/0.4.1',
|
|
113
117
|
},
|
|
114
118
|
body: JSON.stringify(inputs),
|
|
115
119
|
signal: AbortSignal.timeout(8_000),
|
package/dist/outcomes.js
ADDED
|
@@ -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
|
-
|
|
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: [
|
|
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: [
|
|
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
|
|
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
|
package/dist/surfaces.js
ADDED
|
@@ -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.
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
716
|
-
.describe("Restrict the search. Omit to search all
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
1265
|
+
const labs = await mergedLabs(content);
|
|
1016
1266
|
const lab = labs.find((candidate) => candidate.slug === slug);
|
|
1017
|
-
return lab
|
|
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.
|
|
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",
|