MCP konektory: nacte nastroje ze serveru a udela z nich kroky

Firma si zalozi napojeni na svuj MCP server, stiskne Nacist nastroje a jeho
nastroje se objevi v builderu jako kroky automatizace vcetne toho, jake
promenne prijimaji a jake vraceji.

Pribylo:
- sluzba `mcp` - jedina v katalogu bez pevnych operaci, rekne je az server.
  Udaje: adresa serveru, token nebo klic v X-API-Key
- POST /connectors/:id/mcp/tools - zepta se serveru na tools/list a ulozi
  vysledek. Je to zaroven overeni konektoru, proto u MCP neni tlacitko Overit
- src/mcp/client.ts - handshake, sezeni z hlavicky odpovedi, odpoved jako JSON
  i jako SSE stream, strankovani nastroju, nic z toho nevyhazuje vyjimku
- src/mcp/schema.ts - ze schematu vzniknou pole kroku a zpatky se z vyplnenych
  retezcu udelaji argumenty ve spravnych typech. Ten druhy smer je ten
  podstatny: server ceka {"limit": 10}, ne {"limit": "10"}
- src/data/mcpTools.ts - nastroje v katalogu, kes nad tim, co je u konektoru
- sloupec `mcp` u konektoru (migrace 004). Bez ulozeni by po restartu zmizely
  z katalogu kroky, ktere uzivatel uz ma ve stromech
- vnitrni krok runMcpTool - jedna obsluha pro vsechny nastroje vsech serveru

Rozhodnuti:
- nastroj patri firme, ne katalogu. serviceCatalog(tenantId) bez firmy nevrati
  zadny, takze zapomenuty argument znamena "nic", ne "vsechno"
- ID operace nese ID konektoru (tool:<konektor>:<nastroj>), protoze firma muze
  mit dva servery a na obou nastroj `search`
- krok se neopakuje, MCP nema idempotencni klic
- chyba nemaze nastroje, vypadek serveru nesmi vymazat kroky z automatizaci
- servery se pri startu neobvolavaji, jeden nedostupny by shodil katalog vsem

Dokumentace: prepsany 24-mcp-konektory.md na skutecny stav, novy
00-pro-programatory.md (rozcestnik, model ctyr pojmu, pravidla, ktera plati
vsude, co je krehke), doplnene 01, 12 a 99.

Mimochodem opraveno: setStatus v connectors/postgres.ts melo v RETURNING
doslovny retezec ${COLUMNS} misto dosazeni, a dva odstavce v dokumentu 12 byly
dvakrat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-28 09:43:03 +02:00
co-authored by Claude Opus 5
parent 3e6b365eec
commit 435e254c90
22 changed files with 2101 additions and 199 deletions
+156 -1
View File
@@ -26,6 +26,7 @@ import {
getConnector,
listConnectors,
setConnectorStatus,
setConnectorTools,
toPublicConnector,
updateConnector,
validateConnectorValues,
@@ -34,6 +35,7 @@ import {
import {
canSeeService,
findService,
MCP_SERVICE_ID,
serviceCatalog,
serviceCategories,
visibleServices,
@@ -42,6 +44,8 @@ import {
import { config } from '../config.js';
import { egressIp } from '../data/egressIp.js';
import { smtpSettings, smtpTargetUrl, verifySmtp } from '../mail/smtp.js';
import { listTools } from '../mcp/client.js';
import { forgetMcpTools, rememberMcpTools } from '../data/mcpTools.js';
import { resolveTarget, serviceBaseUrl, targetSecrets } from '../scripts/connections.js';
import { createHttp } from '../scripts/http.js';
import { ScriptError } from '../scripts/types.js';
@@ -101,7 +105,10 @@ connectorsRouter.get('/services', async (req, res) => {
const tenantId = requested ?? access.defaultTenantId;
const visible = visibleServices(req.user!, tenantId);
const withScripts = new Map(serviceCatalog().map((service) => [service.id, service]));
// Firma se predava kvuli MCP: nastroje jsou vlastnost jejiho napojeni.
const withScripts = new Map(
serviceCatalog(tenantId ?? null).map((service) => [service.id, service]),
);
const counts = tenantId
? await connectorCountsByService([tenantId])
: new Map<string, number>();
@@ -253,6 +260,9 @@ connectorsRouter.patch('/:id', async (req, res) => {
if (!updated) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
// Nazev konektoru je v nazvu kazdeho jeho nastroje ve vyberu kroku. Bez
// tohohle by tam po prejmenovani zustal stary az do restartu.
if (updated.serviceId === MCP_SERVICE_ID) rememberMcpTools(updated);
return res.json(toPublicConnector(updated));
});
@@ -263,9 +273,104 @@ connectorsRouter.delete('/:id', async (req, res) => {
if (!(await deleteConnector(req.params.id, [tenantId]))) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
// Smazanym konektorem zmizi i jeho nastroje z katalogu, jinak by v builderu
// zustaly kroky, ktere uz nemaji kam volat.
forgetMcpTools(req.params.id);
return res.status(204).end();
});
// ----------------------------------------------------------------------- MCP
/**
* Zepta se MCP serveru na nastroje a ulozi je ke konektoru.
*
* Jedno misto pro dve cesty: tlacitko Nacist nastroje i overeni konektoru
* delaji u MCP totez. Kdyby to bylo dvakrat, jedno by casem umelo neco navic.
*
* Ulozi se **i prazdny vysledek**: server, ktery uz zadny nastroj nenabizi, ma
* v katalogu zmizet. Pri chybe se naopak nemeni nic - vypadek serveru nesmi
* vymazat kroky z automatizaci, ktere uzivatel uz ma postavene.
*/
async function loadMcpTools(connectorId: string, tenantId: string) {
const connector = await getConnector(connectorId, [tenantId]);
if (!connector) return null;
const target = resolveTarget(connector.serviceId, connector);
const checked = 'nástroje serveru';
if (!target.ready) {
const message = `Napojení není hotové: ${target.missing.join(', ')}.`;
await setConnectorStatus(
connector.id,
'error',
message,
{
at: new Date().toISOString(),
ok: false,
checked: 'nic',
status: null,
message,
detail: null,
request: null,
responseHeaders: null,
egressIp: null,
},
[tenantId],
);
return { ok: false, checked: 'nic', message, tools: [] };
}
const outcome = await listTools(target);
await setConnectorStatus(
connector.id,
outcome.ok ? 'ok' : 'error',
outcome.ok ? null : outcome.message,
{
at: new Date().toISOString(),
ok: outcome.ok,
checked,
status: outcome.status,
message: outcome.message,
detail: outcome.detail,
request: outcome.request,
responseHeaders: null,
// Server si zaklada firma, seznamy povolenych IP na nem nemame v ruce.
egressIp: null,
},
[tenantId],
);
if (!outcome.ok || !outcome.value) {
console.warn(`[mcp] ${connector.id}: ${outcome.message}`);
return {
ok: false,
checked,
message: outcome.message,
...(outcome.status !== null ? { status: outcome.status } : {}),
...(outcome.detail ? { detail: outcome.detail } : {}),
request: outcome.request,
tools: [],
};
}
const saved = await setConnectorTools(connector.id, outcome.value, [tenantId]);
if (saved) rememberMcpTools(saved);
const publicView = saved ? toPublicConnector(saved) : null;
console.info(`[mcp] ${connector.id}: ${outcome.message}`);
return {
ok: true,
checked,
message: outcome.message,
request: outcome.request,
server: outcome.value.server,
protocolVersion: outcome.value.protocolVersion,
tools: publicView?.mcp?.tools ?? [],
};
}
// -------------------------------------------------------------------- overeni
/**
@@ -314,6 +419,18 @@ connectorsRouter.post('/:id/test', async (req, res) => {
return res.json({ ok: false, checked: 'nic', message, baseUrl: target.baseUrl });
}
/*
* MCP se overuje tim, ze si rekne o nastroje. Jina cteci operace v protokolu
* neni a `/health` by u nej nedavalo smysl - MCP server zadne nema.
*/
if (service.transport === 'mcp') {
const outcome = await loadMcpTools(connector.id, tenantId);
if (!outcome) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
return res.json({ ...outcome, baseUrl: target.serviceConfig.serverUrl ?? '' });
}
/*
* SMTP se neoveruje ctecim volanim, ale prihlasenim. `verify` nic neposila,
* takze test nikomu nic nedorucí - a pritom bez platneho hesla neprojde,
@@ -468,6 +585,44 @@ connectorsRouter.post('/:id/test', async (req, res) => {
}
});
/**
* Nacteni nastroju MCP serveru.
*
* Tohle je ta cast, kterou ma MCP navic proti ostatnim sluzbam. Jinde je
* seznam operaci nas kod, tady ho rekne az server: portal se zepta `tools/list`
* a z odpovedi vzniknou kroky vcetne toho, jake promenne prijimaji a jake
* vraceji.
*
* Je to zaroven **overeni konektoru**, proto se zapisuje i do historie: kdyz
* server odpovi seznamem, adresa i token sedi. Cteci volani, nic nemeni,
* da se spustit kdykoliv.
*/
connectorsRouter.post('/:id/mcp/tools', async (req, res) => {
const tenantId = tenantOrDeny(req, res);
if (!tenantId) return;
const connector = await getConnector(req.params.id, [tenantId]);
if (!connector) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
const service = serviceOrDeny(req, res, connector.serviceId, tenantId);
if (!service) return;
if (service.transport !== 'mcp') {
return res.status(400).json({
error: 'validation_error',
message: `${service.name} není MCP server, nástroje nemá odkud načíst.`,
});
}
const outcome = await loadMcpTools(connector.id, tenantId);
if (!outcome) {
return res.status(404).json({ error: 'not_found', message: 'Konektor neexistuje.' });
}
// Neuspesne nacteni neni chyba API, je to vysledek. Proto 200.
return res.json(outcome);
});
/**
* Historie overeni konektoru.
*
+3 -1
View File
@@ -903,7 +903,9 @@ dashboardRouter.get('/services', (req, res) => {
res.json({
categories: serviceCategories,
items: withRuntimeOptions(
serviceCatalog().filter((service) => visible.has(service.id)),
// Firma se predava kvuli MCP: nastroje jsou vlastnost jejiho napojeni,
// ne katalogu. Bez ni se nevrati zadne.
serviceCatalog(tenantId ?? null).filter((service) => visible.has(service.id)),
{
people: listPeople(tenantIds).map((person) => ({ id: person.id, name: person.name })),
groups: listGroups(tenantIds).map((group) => ({ id: group.id, name: group.name })),