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
+15
View File
@@ -39,6 +39,7 @@ import { featuresStore, refreshFeatures, seedFeatures } from './tenantFeatures.j
import { refreshTenants, seedTenants, tenantStore } from './tenants.js';
import { refreshTicketTypes, seedTicketTypes, ticketTypeStore } from './ticketTypes.js';
import { refreshUsers, seedUsers, userStore } from './users.js';
import { refreshMcpTools } from './mcpTools.js';
import { initTickets } from './ticketStore.js';
import { initAutomations } from './automationStore.js';
import { initLayouts } from './dashboardLayouts.js';
@@ -140,6 +141,20 @@ export async function bootstrapData(options: { databaseReady: boolean }): Promis
await refreshCaches();
console.info(`[data] nactena uloziste (${mode}): ${entities.map((e) => e.store.kind).join(', ')}`);
/*
* Nastroje MCP serveru do katalogu. Az po firmach, protoze konektory se
* ctou po firmach. Cte se jen to, co uz je ulozene - servery se pri startu
* neobvolavaji, jeden nedostupny by shodil katalog vsem.
*/
try {
await refreshMcpTools();
} catch (err) {
console.error(
'[data] nastroje MCP se nepodarilo nacist:',
err instanceof Error ? err.message : err,
);
}
/*
* Tickety az po resitelich a typech: `toTicket` dohledava resitele podle ID
* a bez nich by u kazdeho ticketu hlasil, ze resitel neexistuje.
+16
View File
@@ -31,6 +31,7 @@ import type {
UpdateConnectorInput,
} from './connectors/types.js';
import { fileSnapshot, memorySnapshot } from './snapshot.js';
import type { McpToolset } from '../mcp/client.js';
export type {
Connector,
@@ -223,6 +224,21 @@ export function deleteConnector(id: string, tenantIds: string[]): Promise<boolea
return repository.remove(id, tenantIds);
}
/**
* Ulozi nastroje MCP serveru ke konektoru.
*
* Vlastni funkce, ne soucast `updateConnector`: nastroje nejsou nastaveni od
* uzivatele, ale to, co rekl server. Kdyby sly stejnou cestou jako udaje,
* shodil by kazdy zapis stav konektoru na neovereny.
*/
export function setConnectorTools(
id: string,
tools: McpToolset | null,
tenantIds: string[],
): Promise<Connector | undefined> {
return repository.setTools(id, tools, tenantIds);
}
/** Vysledek overeni napojeni. Zapisuje ho endpoint pro test. */
export function setConnectorStatus(
id: string,
+25 -4
View File
@@ -18,6 +18,7 @@ import { randomUUID } from 'node:crypto';
import { openAll, sealAll } from '../../db/secretBox.js';
import { findService } from '../services.js';
import type { SnapshotStore } from '../snapshot.js';
import type { McpToolset } from '../../mcp/client.js';
import {
CHECK_HISTORY,
nowIso,
@@ -85,9 +86,14 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
rows.length = 0;
for (const item of stored) {
const { secrets, ...rest } = item;
// Soubor zapsany starsi verzi historii overeni nema. Chybejici pole
// je prazdna historie, ne duvod, proc by uloziste nemelo nastartovat.
rows.push({ ...rest, values: openAll(secrets), checks: rest.checks ?? [] });
// Soubor zapsany starsi verzi nema historii ani nastroje. Chybejici
// pole je prazdna hodnota, ne duvod, proc by uloziste nemelo nastartovat.
rows.push({
...rest,
values: openAll(secrets),
checks: rest.checks ?? [],
mcp: rest.mcp ?? null,
});
}
await seed();
},
@@ -144,6 +150,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
lastCheckAt: null,
lastError: null,
checks: [],
mcp: null,
// Prvni konektor na sluzbu je vychozi, jinak by krok bez vyberu nemel co vzit.
isDefault: input.isDefault ?? existing.length === 0,
createdAt: timestamp,
@@ -169,7 +176,10 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
if (value === '') delete row.values[key];
else row.values[key] = value;
}
// Zmena udaju znamena, ze predchozi overeni uz nic nerika.
// Zmena udaju znamena, ze predchozi overeni uz nic nerika. Nastroje
// se ale nemazou: jina adresa muze vratit jiny seznam, jenze dokud ho
// nekdo nenacte, jsou ty stare porad to jedine, co v ulozenych
// automatizacich drzi kroky nazivu. Zmizet by musely i z nich.
row.status = 'untested';
row.lastError = null;
row.checks = [];
@@ -216,6 +226,16 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
persist();
return copy(row);
},
async setTools(id, tools: McpToolset | null, tenantIds) {
const row = rows.find((item) => item.id === id);
if (!row || !tenantIds.includes(row.tenantId)) return undefined;
row.mcp = tools;
row.updatedAt = nowIso();
persist();
return copy(row);
},
};
/**
@@ -242,6 +262,7 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
lastCheckAt: null,
lastError: null,
checks: [],
mcp: null,
isDefault: true,
createdAt: timestamp,
updatedAt: timestamp,
+17 -2
View File
@@ -11,6 +11,7 @@
*/
import { randomUUID } from 'node:crypto';
import type { McpToolset } from '../../mcp/client.js';
import { query, queryOne, transaction } from '../../db/pool.js';
import { openAll, sealAll } from '../../db/secretBox.js';
import { CHECK_HISTORY } from './types.js';
@@ -34,6 +35,7 @@ interface ConnectorRow {
last_check_at: Date | null;
last_error: string | null;
checks: unknown;
mcp: unknown;
is_default: boolean;
created_at: Date;
updated_at: Date;
@@ -52,6 +54,7 @@ function toConnector(row: ConnectorRow): Connector {
lastCheckAt: row.last_check_at ? row.last_check_at.toISOString() : null,
lastError: row.last_error,
checks: Array.isArray(row.checks) ? (row.checks as ConnectorCheck[]) : [],
mcp: row.mcp !== null && typeof row.mcp === 'object' ? (row.mcp as McpToolset) : null,
isDefault: row.is_default,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
@@ -60,7 +63,7 @@ function toConnector(row: ConnectorRow): Connector {
const COLUMNS = `
id, tenant_id, service_id, name, base_url, secrets, enabled, status,
last_check_at, last_error, checks, is_default, created_at, updated_at
last_check_at, last_error, checks, mcp, is_default, created_at, updated_at
`;
export const postgresConnectors: ConnectorRepository = {
@@ -259,9 +262,21 @@ export const postgresConnectors: ConnectorRepository = {
last_check_at = now(),
updated_at = now()
WHERE id = $1 AND tenant_id = ANY($2)
RETURNING ${'${COLUMNS}'}`,
RETURNING ${COLUMNS}`,
[id, tenantIds, status, error, check ? JSON.stringify([check]) : null],
);
return row ? toConnector(row) : undefined;
},
async setTools(id, tools: McpToolset | null, tenantIds) {
if (tenantIds.length === 0) return undefined;
const row = await queryOne<ConnectorRow>(
`UPDATE connectors
SET mcp = $3::jsonb, updated_at = now()
WHERE id = $1 AND tenant_id = ANY($2)
RETURNING ${COLUMNS}`,
[id, tenantIds, tools ? JSON.stringify(tools) : null],
);
return row ? toConnector(row) : undefined;
},
};
+67 -1
View File
@@ -9,6 +9,8 @@
*/
import { findService, type Service, type ServiceCredentialField } from '../services.js';
import { fieldsFromSchema, outputsFromTool } from '../../mcp/schema.js';
import type { McpToolset } from '../../mcp/client.js';
/**
* Jeden zaznam o overeni konektoru.
@@ -64,6 +66,16 @@ export interface Connector {
checks: ConnectorCheck[];
/** Krok stromu bez vybraneho konektoru pouzije vychozi. */
isDefault: boolean;
/**
* Nastroje MCP serveru tak, jak je server naposled rekl. `null` u vsech
* ostatnich sluzeb a u MCP konektoru, u ktereho se jeste nenacetly.
*
* Uklada se to, protoze na tom stoji katalog: po restartu by jinak z
* automatizaci zmizely kroky, ktere v nich uzivatel ma. Server se pri startu
* neobvolava - byl by to desitky volani ven jen kvuli tomu, aby aplikace
* nastartovala, a nedostupny server by shodil katalog.
*/
mcp: McpToolset | null;
createdAt: string;
updatedAt: string;
}
@@ -99,6 +111,28 @@ export interface PublicConnector {
config: Record<string, string>;
/** true = vsechna povinna pole jsou vyplnena, jde volat. */
ready: boolean;
/**
* Nastroje MCP serveru pro portal. Schemata tu **nejsou** - klient je
* nepotrebuje, uz prevedena na pole kroku chodi v katalogu sluzeb. Tady jde
* jen o to ukazat u konektoru, co se nacetlo.
*/
mcp: PublicToolset | null;
}
/** Nastroje jednoho serveru tak, jak je vidi portal. */
export interface PublicToolset {
at: string;
server: string;
protocolVersion: string;
tools: Array<{
name: string;
label: string;
description: string;
/** Nazvy parametru, ktere nastroj prijima. Povinne jsou prvni. */
inputs: string[];
/** Nazvy hodnot, ktere vraci. Vzdy aspon text a priznak chyby. */
outputs: string[];
}>;
}
export interface CreateConnectorInput {
@@ -149,6 +183,12 @@ export interface ConnectorRepository {
check: ConnectorCheck | null,
tenantIds: string[],
): Promise<Connector | undefined>;
/** Ulozi nastroje MCP serveru. Vlastni metoda, aby se nemichaly s udaji. */
setTools(
id: string,
tools: McpToolset | null,
tenantIds: string[],
): Promise<Connector | undefined>;
}
// ------------------------------------------------------------------- pomocne
@@ -187,7 +227,7 @@ export function toPublicConnector(connector: Connector): PublicConnector {
const missing = service ? missingFields(service, connector.values) : [];
const { values: _values, checks, ...rest } = connector;
const { values: _values, checks, mcp, ...rest } = connector;
return {
...rest,
checkCount: checks.length,
@@ -195,6 +235,32 @@ export function toPublicConnector(connector: Connector): PublicConnector {
missing,
config,
ready: missing.length === 0,
mcp: mcp ? toPublicToolset(mcp) : null,
};
}
/**
* Nastroje pro portal.
*
* Schema se prevadi na jmena poli tou samou funkci, kterou se stavi kroky
* v katalogu. Kdyby to byly dva prevody, portal by u konektoru ukazoval jina
* pole, nez jakymi se nastroj opravdu vola - a to je presne ta chyba, ktera se
* pozna az u zakaznika.
*/
function toPublicToolset(toolset: McpToolset): PublicToolset {
return {
at: toolset.at,
server: toolset.server,
protocolVersion: toolset.protocolVersion,
tools: toolset.tools.map((tool) => ({
name: tool.name,
label: tool.title ?? tool.name,
description: tool.description,
inputs: fieldsFromSchema(tool.inputSchema).map(
(field) => `${field.label}${field.required ? ' *' : ''}`,
),
outputs: outputsFromTool(tool).map((field) => field.id),
})),
};
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Nastroje MCP serveru v katalogu.
*
* Zbytek katalogu je znamy pri prekladu: sluzba iDoklad ma operace, ktere jsme
* napsali my. MCP je jine - **co server umi, se zjisti az od nej**. Firma si
* zalozi konektor, stiskne Nacist nastroje a teprve tim vznikne seznam operaci,
* ktere jde davat do automatizaci.
*
* Tenhle soubor drzi ten seznam v pameti a doplnuje ho do katalogu. Je to
* stejny vzorec jako u skriptu (`setScriptActions`) a u resitelu
* (`withRuntimeOptions`): katalog zustava zdrojem pravdy, jen se do nej doplni
* to, co pri importu modulu jeste nebylo.
*
* Trvale ulozeni je u konektoru (`Connector.mcp`), tohle je jen kes. Po
* restartu se plni v `bootstrapData`, pri kazdem nacteni nastroju z portalu se
* prepise.
*/
import type { Connector } from './connectorStore.js';
import { listConnectors } from './connectorStore.js';
import { MCP_SERVICE_ID, setMcpOperations, type ServiceOperation } from './services.js';
import { listTenants } from './tenants.js';
import { fieldsFromSchema, outputsFromTool } from '../mcp/schema.js';
import type { McpTool } from '../mcp/client.js';
/**
* ID operace nese ID konektoru.
*
* Duvod: firma muze mit dva MCP servery a na obou nastroj `search`. Kdyby ID
* operace bylo jen `search`, krok by nemel jak rict, ktery z nich. Ostatni
* sluzby to nemaji, protoze u nich je operace vlastnost sluzby, ne napojeni -
* u MCP je to naopak.
*/
function operationId(connectorId: string, toolName: string): string {
return `tool:${connectorId}:${toolName}`;
}
/** Rozlozi ID operace zpatky. `null`, kdyz to ID nastroje MCP neni. */
export function parseOperationId(id: string): { connectorId: string; toolName: string } | null {
if (!id.startsWith('tool:')) return null;
const rest = id.slice(5);
const separator = rest.indexOf(':');
if (separator <= 0 || separator === rest.length - 1) return null;
return { connectorId: rest.slice(0, separator), toolName: rest.slice(separator + 1) };
}
interface Entry {
connectorId: string;
connectorName: string;
tenantId: string;
tools: McpTool[];
}
/** Klic je ID konektoru. Kazdy server ma svuj seznam. */
const byConnector = new Map<string, Entry>();
/**
* Operace z jednoho nastroje.
*
* V nazvu je i jmeno konektoru, protoze ve vyberu kroku jsou nastroje vsech
* serveru pod jednou sluzbou. Bez nej by tam byly dva radky `search` a nedalo
* by se poznat, ktery je ktery.
*/
function toOperation(entry: Entry, tool: McpTool): ServiceOperation {
const label = tool.title ?? tool.name;
return {
id: operationId(entry.connectorId, tool.name),
name: `${entry.connectorName}: ${label}`,
description: tool.description || `Nástroj ${tool.name} na serveru ${entry.connectorName}.`,
inputs: fieldsFromSchema(tool.inputSchema),
outputFields: outputsFromTool(tool),
// Vykonna cast neni skript, ale vnitrni krok - vsechny nastroje obsluhuje
// jeden. Priznak `implementation` je jen pro skripty, proto tu neni.
};
}
/** Prepocita, co se posila do katalogu. Vola se po kazde zmene mapy. */
function publish(): void {
const items: Array<{ tenantId: string; operation: ServiceOperation }> = [];
for (const entry of byConnector.values()) {
for (const tool of entry.tools) {
items.push({ tenantId: entry.tenantId, operation: toOperation(entry, tool) });
}
}
setMcpOperations(items);
}
/** Zapamatuje si nastroje konektoru. Prazdny seznam zaznam odstrani. */
export function rememberMcpTools(connector: Connector): void {
const tools = connector.mcp?.tools ?? [];
if (tools.length === 0) {
byConnector.delete(connector.id);
} else {
byConnector.set(connector.id, {
connectorId: connector.id,
connectorName: connector.name,
tenantId: connector.tenantId,
tools,
});
}
publish();
}
/** Smazany konektor uz nema co nabizet. */
export function forgetMcpTools(connectorId: string): void {
if (byConnector.delete(connectorId)) publish();
}
/** Jeden nastroj jednoho konektoru. Pro vykonnou cast kroku. */
export function findMcpTool(
connectorId: string,
toolName: string,
): { tool: McpTool; connectorName: string } | undefined {
const entry = byConnector.get(connectorId);
const tool = entry?.tools.find((item) => item.name === toolName);
return entry && tool ? { tool, connectorName: entry.connectorName } : undefined;
}
/**
* Nacte nastroje vsech konektoru z uloziste do pameti.
*
* Vola se pri startu. Bez toho by po restartu zmizely vsechny kroky s MCP
* z katalogu a ulozene automatizace by hlasily neznamou operaci, dokud by
* nekdo rucne nestiskl Nacist nastroje.
*/
export async function refreshMcpTools(): Promise<void> {
const tenantIds = listTenants().map((tenant) => tenant.id);
byConnector.clear();
if (tenantIds.length > 0) {
const connectors = await listConnectors(tenantIds, { serviceId: MCP_SERVICE_ID });
for (const connector of connectors) {
const tools = connector.mcp?.tools ?? [];
if (tools.length === 0) continue;
byConnector.set(connector.id, {
connectorId: connector.id,
connectorName: connector.name,
tenantId: connector.tenantId,
tools,
});
}
}
publish();
}
+137 -7
View File
@@ -214,11 +214,13 @@ export interface Service {
* `http` (vychozi) je zbytek katalogu: skript rekne cestu a runtime doplni
* adresu a hlavicky. `smtp` je e-mail - neni to HTTP, takze operaci nevykona
* skript, ale vnitrni krok, a overeni konektoru se misto cteciho volani
* prihlasi na posmovni server.
* prihlasi na posmovni server. `mcp` je JSON-RPC nad HTTP, kde se pred
* kazdym volanim navazuje sezeni a operace nejsou v katalogu - rekne je
* az server.
*
* Je to priznak sluzby, ne konektoru: jak se sluzba vola, je jeji vlastnost.
*/
transport?: 'http' | 'smtp';
transport?: 'http' | 'smtp' | 'mcp';
/**
* Absolutni adresa sluzby, ktera **nebezi u nas**. Typicky OpenAI.
*
@@ -286,6 +288,13 @@ export const serviceCategories: Array<{ id: ServiceCategory; label: string }> =
{ id: 'transformace', label: 'Transformace dat' },
];
/**
* ID sluzby, pod kterou visi nastroje vsech MCP serveru.
*
* Nahore, protoze na nej odkazuje uz samotny katalog nize.
*/
export const MCP_SERVICE_ID = 'mcp';
export const services: Service[] = [
// ------------------------------------------------- obecne: spoustece
{
@@ -2398,6 +2407,67 @@ export const services: Service[] = [
],
},
/**
* MCP server firmy.
*
* Jedina sluzba v katalogu, ktera **nema zadne pevne operace**. Co umi, rekne
* az server: konektor se zalozi, stiskne se Nacist nastroje a teprve tim
* vzniknou kroky, ktere jde davat do automatizaci. Doplnuje je
* `src/data/mcpTools.ts`.
*
* Adresa serveru je mezi udaji, ne v poli "vlastni adresa sluzby". U ostatnich
* sluzeb je adresa vlastnost sluzby a konektor ji smi jen prepsat, tady je to
* naopak: sluzba zadnou adresu nema, protoze kazda firma ma svuj server.
* Stejne to ma SMTP.
*/
{
id: MCP_SERVICE_ID,
name: 'MCP server',
category: 'ai',
description:
'Napojení na vlastní MCP server. Portál si od něj vyžádá seznam nástrojů a ty se pak dají použít jako kroky automatizace.',
icon: 'Plug',
status: 'available',
general: false,
appId: null,
transport: 'mcp',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'serverUrl',
label: 'Adresa MCP serveru',
target: 'config',
name: 'serverUrl',
required: true,
secret: false,
hint: 'Celá adresa endpointu, například https://mcp.firma.cz/mcp. Musí být dostupná z internetu.',
},
{
id: 'token',
label: 'Token',
target: 'header',
name: 'Authorization',
// Uzivatel vlepi token tak, jak ho dostal. Slovo Bearer dopise runtime.
prefix: 'Bearer ',
required: false,
secret: true,
hint: 'Posílá se jako Authorization: Bearer. Vložte jen token, slovo Bearer doplní portál.',
},
{
id: 'apiKey',
label: 'API klíč v hlavičce X-API-Key',
target: 'header',
name: 'X-API-Key',
required: false,
secret: true,
hint: 'Pro servery, které místo tokenu chtějí klíč v téhle hlavičce. Vyplňte jedno, nebo druhé.',
},
],
triggers: [],
// Prazdne zamerne: vsechny operace jsou nastroje ze serveru.
actions: [],
},
/**
* Ukazka omezene viditelnosti: tuhle sluzbu vidi jen LogiTrans a spravce
* platformy. Ostatni firmy ji v katalogu vubec nedostanou, takze se ani
@@ -2657,6 +2727,54 @@ export function setScriptActions(byService: Map<string, ServiceOperation[]>): vo
}
}
/**
* Nastroje MCP serveru.
*
* Druhy prekryv katalogu, a jineho druhu nez skripty. Skript je nas kod, takze
* je znamy pri prekladu. Nastroj MCP je **cizi a zjisti se az od serveru**,
* proto s sebou nese firmu: co ma jedna firma na svem serveru, druhe do
* katalogu nepatri.
*
* Plni to `src/data/mcpTools.ts`.
*/
let mcpOperations: Array<{ tenantId: string; operation: ServiceOperation }> = [];
/** Nahradi cely seznam nastroju. */
export function setMcpOperations(
items: Array<{ tenantId: string; operation: ServiceOperation }>,
): void {
mcpOperations = items;
}
const byName = (a: ServiceOperation, b: ServiceOperation): number =>
a.name.localeCompare(b.name, 'cs');
/**
* Nastroje **jedne firmy**. `null` znamena zadne, ne vsechny.
*
* Firma bez vybrane firmy v adrese nema videt nastroje cizich serveru, a to ani
* jmenem. Nazev nastroje umi prozradit dost: `zrus_objednavku_v_soap_bridge`
* rekne o cizi firme vic, nez by melo.
*/
export function mcpActionsFor(tenantId: string | null): ServiceOperation[] {
if (tenantId === null) return [];
return mcpOperations
.filter((item) => item.tenantId === tenantId)
.map((item) => item.operation)
.sort(byName);
}
/**
* Nastroje napric firmami.
*
* Jen pro vnitrni dohledani operace (`findOperation`, dosazovani sablon).
* Ven se to neposila - od toho je `mcpActionsFor`.
*/
function allMcpActions(): ServiceOperation[] {
return mcpOperations.map((item) => item.operation).sort(byName);
}
/**
* Akce sluzby vcetne tech ze skriptu.
* Kdyz skript nese ID operace, ktera uz v katalogu je, **skript vyhrava**.
@@ -2666,6 +2784,9 @@ export function actionsFor(serviceId: string): ServiceOperation[] {
const service = findService(serviceId);
if (!service) return [];
// MCP nema skripty, ma nastroje serveru. Napric firmami, viz `allMcpActions`.
if (serviceId === MCP_SERVICE_ID) return [...service.actions, ...allMcpActions()];
const fromScripts = scriptActions.get(serviceId);
if (!fromScripts || fromScripts.length === 0) return service.actions;
@@ -2729,11 +2850,20 @@ export function withRuntimeOptions(
}));
}
/** Katalog sluzeb vcetne akci ze skriptu. Nemodifikuje `services`. */
export function serviceCatalog(): Service[] {
return services.map((service) =>
scriptActions.has(service.id) ? { ...service, actions: actionsFor(service.id) } : service,
);
/**
* Katalog sluzeb vcetne akci ze skriptu. Nemodifikuje `services`.
*
* `tenantId` je potreba kvuli MCP: nastroje jsou vlastnost napojeni jedne
* firmy, ne sluzby. Bez nej se zadne nevraci, coz je spravna vychozi hodnota -
* zapomenuty argument tak neznamena "vsechny".
*/
export function serviceCatalog(tenantId: string | null = null): Service[] {
return services.map((service) => {
if (service.id === MCP_SERVICE_ID) {
return { ...service, actions: [...service.actions, ...mcpActionsFor(tenantId)] };
}
return scriptActions.has(service.id) ? { ...service, actions: actionsFor(service.id) } : service;
});
}
/**
+17
View File
@@ -0,0 +1,17 @@
-- Nastroje MCP serveru u konektoru.
--
-- MCP je jedina sluzba, u ktere operace neurcuje katalog, ale az sam server:
-- konektor se zalozi, portal si vyzada `tools/list` a teprve tim vzniknou
-- kroky, ktere jde davat do automatizaci.
--
-- Proc je to sloupec a ne jen kes v pameti: bez nej by po kazdem restartu
-- zmizely z katalogu kroky, ktere uzivatel uz ma ve svych stromech, a strom by
-- hlasil neznamou operaci. Obvolavat pri startu servery vsech firem nejde -
-- jeden nedostupny by shodil katalog vsem.
--
-- Uvnitr je cely `McpToolset`, tedy cas nacteni, jak se server predstavil
-- a schemata vsech nastroju. Zadny pristupovy udaj v tom neni, ty zustavaji
-- v `secrets`.
ALTER TABLE connectors
ADD COLUMN IF NOT EXISTS mcp jsonb;
+451
View File
@@ -0,0 +1,451 @@
/**
* Klient MCP (Model Context Protocol).
*
* MCP server vystavuje **nastroje**: kazdy ma jmeno, popis a schema toho, co
* prijima a co vraci. Klient si o ne rekne (`tools/list`) a pak je vola
* (`tools/call`). Presne o to tady jde: konektor drzi adresu a token, tenhle
* soubor s nim mluvi.
*
* Proc to nejde pres `ctx.http` jako zbytek sluzeb:
* - MCP vraci odpoved bud jako JSON, **nebo jako SSE stream**, a to `ctx.http`
* nerozlisuje,
* - server muze zalozit sezeni a jeho ID posila **v hlavicce odpovedi**,
* kterou `ScriptHttpResponse` nenese,
* - pred prvnim volanim je povinny handshake (`initialize`).
*
* Je to stejny duvod, proc ma vlastni soubor i SMTP: protokol, ktery se do
* "zavolej cestu a vrat telo" nevejde. Co jde pouzit spolecne, se pouziva -
* kontrola vnitrni site i redakce tajemstvi jsou tytez funkce jako u HTTP.
*
* **Nic z toho nevyhazuje vyjimku.** Nedostupny server firmy neni chyba
* portalu, je to vysledek, ktery se ma ukazat u konektoru.
*/
import { config } from '../config.js';
import { targetSecrets, type ResolvedTarget } from '../scripts/connections.js';
import { isPrivateHost } from '../scripts/http.js';
import { createRedactor, truncate } from '../scripts/util.js';
/**
* Verze protokolu, kterou umime.
*
* Server smi odpovedet jinou - pak plati jeho a posila se dal v hlavicce
* `MCP-Protocol-Version`. Vnucovat mu nasi by znamenalo, ze novejsi server
* prestane fungovat, aniz by se u nas cokoliv zmenilo.
*/
const PROTOCOL_VERSION = '2025-06-18';
/** Kdo se predstavi serveru. Nektere servery si to pisou do logu. */
const CLIENT_INFO = { name: 'worknuke', version: '1' };
/**
* Strop na strankovani `tools/list`.
*
* Server vraci nastroje po strankach a rika kurzor na dalsi. Rozbity server
* muze vracet porad tentyz kurzor, takze bez stropu by se cyklilo donekonecna.
*/
const MAX_PAGES = 20;
/** Schema podle JSON Schema. Tvar se prochazi az v `schema.ts`. */
export type JsonSchema = Record<string, unknown>;
/** Jeden nastroj tak, jak ho popsal server. */
export interface McpTool {
name: string;
/** Hezky nazev pro cloveka. Nepovinny, casto chybi. */
title?: string;
description: string;
/** Co nastroj prijima. Vzdy objekt, i kdyz prazdny. */
inputSchema: JsonSchema;
/**
* Co nastroj vraci. **Nepovinne** a vetsina serveru to nema - pak je znamy
* jen text odpovedi, ne jednotliva pole. Neni to nedodelek u nas.
*/
outputSchema?: JsonSchema;
}
/** Vysledek nacteni nastroju jednoho serveru. Uklada se ke konektoru. */
export interface McpToolset {
/** ISO cas nacteni. Podle nej se pozna, jak stary seznam clovek vidi. */
at: string;
/** Jak se server predstavil, vcetne verze. */
server: string;
protocolVersion: string;
tools: McpTool[];
}
/** Vysledek jednoho volani nastroje. */
export interface McpCallResult {
/** Textova cast odpovedi, spojena pres vsechny bloky. */
text: string;
/** Strukturovana cast. Ma ji jen nastroj, ktery deklaruje `outputSchema`. */
structured: Record<string, unknown> | null;
/** true = nastroj rekl, ze se nepovedlo. Neni to chyba spojeni. */
isError: boolean;
}
/**
* Vysledek operace. Stejny tvar jako u SMTP, protoze to resi totez: volani
* ven, ktere smi selhat, a chyba je informace pro uzivatele, ne vyjimka.
*/
export interface McpOutcome<T> {
ok: boolean;
message: string;
/** Cela odpoved serveru, zredigovana a zkracena. */
detail: string | null;
/** HTTP kod. null, kdyz se k volani vubec nedoslo. */
status: number | null;
request: { method: string; path: string; url: string } | null;
value: T | null;
}
/** Chyba uvnitr tohoto souboru. Ven se nedostane, prevede se na `McpOutcome`. */
class McpFailure extends Error {
constructor(
message: string,
readonly status: number | null = null,
readonly detail: string | null = null,
) {
super(message);
this.name = 'McpFailure';
}
}
/**
* Adresa serveru z konektoru.
*
* Vyplnuje ji firma, takze se kontroluje totez co u HTTP a SMTP: jen http(s)
* a ne do vnitrni site. Bez toho by si kdokoliv s pravem zalozit konektor mohl
* nechat navazat spojeni na cokoliv, co je z containeru videt.
*/
function serverUrl(target: ResolvedTarget): URL {
const raw = (target.serviceConfig.serverUrl ?? '').trim();
if (raw === '') throw new McpFailure('Adresa MCP serveru není vyplněná.');
let url: URL;
try {
url = new URL(raw);
} catch {
throw new McpFailure(`Adresa ${raw} není platná URL.`);
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new McpFailure(`Adresa ${url.protocol} není povolená, jen http a https.`);
}
if (!config.allowPrivateTargets && isPrivateHost(url.hostname)) {
throw new McpFailure(
`Adresa ${url.hostname} míří do vnitřní sítě. ` +
'Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.',
);
}
return url;
}
/**
* Odpoved MCP serveru muze prijit jako SSE stream.
*
* Tvar je `data: {...}` na radek, bloky oddelene prazdnym radkem. Bere se
* prvni blok, ktery vypada jako odpoved JSON-RPC - notifikace o prubehu,
* ktere server posila pred nim, nas nezajimaji.
*/
function parseEventStream(raw: string): unknown {
for (const line of raw.split(/\r?\n/)) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (payload === '') continue;
try {
const parsed: unknown = JSON.parse(payload);
if (parsed !== null && typeof parsed === 'object' && 'id' in parsed) return parsed;
} catch {
// Nekompletni blok neni duvod skoncit, dalsi radek muze byt v poradku.
continue;
}
}
throw new McpFailure('Server odpověděl streamem, ve kterém není odpověď JSON-RPC.');
}
interface Session {
url: URL;
headers: Record<string, string>;
/** ID sezeni z hlavicky odpovedi. Server ho mit nemusi. */
sessionId: string | null;
protocolVersion: string;
redact: (value: string) => string;
}
let nextId = 1;
/**
* Jedno volani JSON-RPC.
*
* `expectResult: false` je pro notifikace - na ty server neodpovida telem,
* jen kodem 202.
*/
async function rpc(
session: Session,
method: string,
params: Record<string, unknown> | undefined,
signal: AbortSignal,
expectResult = true,
): Promise<unknown> {
const id = nextId++;
const body = expectResult
? { jsonrpc: '2.0', id, method, ...(params ? { params } : {}) }
: { jsonrpc: '2.0', method, ...(params ? { params } : {}) };
let response: Response;
try {
response = await fetch(session.url, {
method: 'POST',
signal,
headers: {
'Content-Type': 'application/json',
// Obojí, protoze server si vybira, jestli odpovi telem nebo streamem.
Accept: 'application/json, text/event-stream',
'MCP-Protocol-Version': session.protocolVersion,
...(session.sessionId ? { 'Mcp-Session-Id': session.sessionId } : {}),
...session.headers,
},
body: JSON.stringify(body),
});
} catch (err) {
const name = err instanceof Error ? err.name : '';
if (name === 'AbortError' || name === 'TimeoutError') {
throw new McpFailure(`Server ${session.url.host} neodpověděl v limitu.`);
}
const reason = err instanceof Error ? err.message : String(err);
throw new McpFailure(`Nepodařilo se spojit se serverem ${session.url.host}: ${reason}`);
}
// Sezeni zaklada server pri prvnim volani a pak ho vyzaduje u dalsich.
const issued = response.headers.get('mcp-session-id');
if (issued) session.sessionId = issued;
const raw = await response.text();
if (raw.length > config.scriptMaxResponseBytes) {
throw new McpFailure(
`Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`,
response.status,
);
}
const detail = session.redact(truncate(raw, config.errorDetailBytes));
if (!response.ok) {
throw new McpFailure(
`${method} vrátilo HTTP ${response.status}.` +
(response.status === 401 || response.status === 403
? ' Server přístup odmítl, jde tedy o token nebo o oprávnění účtu, ne o adresu.'
: ''),
response.status,
detail === '' ? null : detail,
);
}
if (!expectResult) return undefined;
if (raw.trim() === '') {
throw new McpFailure(`${method} vrátilo prázdnou odpověď.`, response.status);
}
const isStream = response.headers.get('content-type')?.includes('event-stream') ?? false;
let parsed: unknown;
if (isStream) {
parsed = parseEventStream(raw);
} else {
try {
parsed = JSON.parse(raw);
} catch {
throw new McpFailure(
`${method} nevrátilo platný JSON. Míří adresa opravdu na MCP server?`,
response.status,
detail,
);
}
}
const envelope = parsed as { result?: unknown; error?: { code?: number; message?: string } };
if (envelope.error) {
// Chyba protokolu, ne chyba prenosu. Server napsal, co mu vadilo.
throw new McpFailure(
`Server odmítl ${method}: ${envelope.error.message ?? 'bez zprávy'}`,
response.status,
detail,
);
}
return envelope.result;
}
/**
* Handshake.
*
* Bez nej server dalsi volani odmitne. Soucasti je i notifikace
* `notifications/initialized` - tou klient rika, ze je pripraven, a teprve
* pak smi volat nastroje.
*/
async function openSession(
target: ResolvedTarget,
signal: AbortSignal,
): Promise<{ session: Session; server: string }> {
const session: Session = {
url: serverUrl(target),
headers: target.headers,
sessionId: null,
protocolVersion: PROTOCOL_VERSION,
redact: createRedactor(targetSecrets(target)),
};
const result = (await rpc(
session,
'initialize',
{ protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO },
signal,
)) as { protocolVersion?: string; serverInfo?: { name?: string; version?: string } };
// Plati verze serveru. Nase je jen navrh.
if (typeof result?.protocolVersion === 'string') {
session.protocolVersion = result.protocolVersion;
}
await rpc(session, 'notifications/initialized', undefined, signal, false);
const name = result?.serverInfo?.name ?? 'neznámý server';
const version = result?.serverInfo?.version;
return { session, server: version ? `${name} ${version}` : name };
}
/** Prevede zaznam ze serveru na `McpTool`. Vraci null, kdyz to nastroj neni. */
function toTool(value: unknown): McpTool | null {
if (value === null || typeof value !== 'object') return null;
const row = value as Record<string, unknown>;
if (typeof row.name !== 'string' || row.name.trim() === '') return null;
const schema =
row.inputSchema !== null && typeof row.inputSchema === 'object'
? (row.inputSchema as JsonSchema)
: { type: 'object', properties: {} };
return {
name: row.name,
...(typeof row.title === 'string' && row.title.trim() !== '' ? { title: row.title } : {}),
description: typeof row.description === 'string' ? row.description : '',
inputSchema: schema,
...(row.outputSchema !== null && typeof row.outputSchema === 'object'
? { outputSchema: row.outputSchema as JsonSchema }
: {}),
};
}
/** Obal, ktery z vyjimky udela vysledek. Ven z tohoto souboru nic nevyhazuje. */
async function attempt<T>(
target: ResolvedTarget,
run: (signal: AbortSignal) => Promise<T>,
onSuccess: (value: T) => string,
): Promise<McpOutcome<T>> {
let request: McpOutcome<T>['request'] = null;
try {
const url = serverUrl(target);
request = { method: 'POST', path: url.pathname, url: `${url.origin}${url.pathname}` };
} catch {
// Spatna adresa. Rekne to `run`, ktere spadne na tomtez.
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.scriptTimeoutMs);
try {
const value = await run(controller.signal);
return { ok: true, message: onSuccess(value), detail: null, status: 200, request, value };
} catch (err) {
const failure = err instanceof McpFailure ? err : null;
const redact = createRedactor(targetSecrets(target));
const message = failure ? failure.message : err instanceof Error ? err.message : String(err);
return {
ok: false,
message: redact(message),
detail: failure?.detail ?? null,
status: failure?.status ?? null,
request,
value: null,
};
} finally {
clearTimeout(timer);
}
}
/**
* Nacte seznam nastroju serveru.
*
* Tohle je zaroven overeni konektoru: kdyz server odpovi seznamem, adresa
* i token sedí. Nic se pri tom nemeni, takze to jde spustit kdykoliv.
*/
export function listTools(target: ResolvedTarget): Promise<McpOutcome<McpToolset>> {
return attempt(
target,
async (signal) => {
const { session, server } = await openSession(target, signal);
const tools: McpTool[] = [];
let cursor: string | undefined;
for (let page = 0; page < MAX_PAGES; page += 1) {
const result = (await rpc(session, 'tools/list', cursor ? { cursor } : {}, signal)) as {
tools?: unknown[];
nextCursor?: string;
};
for (const item of result?.tools ?? []) {
const tool = toTool(item);
if (tool) tools.push(tool);
}
const next = typeof result?.nextCursor === 'string' ? result.nextCursor : undefined;
// Stejny kurzor podruhe by znamenal nekonecnou smycku.
if (!next || next === cursor) break;
cursor = next;
}
const toolset: McpToolset = {
at: new Date().toISOString(),
server,
protocolVersion: session.protocolVersion,
tools: tools.sort((a, b) => a.name.localeCompare(b.name, 'cs')),
};
return toolset;
},
(value) =>
value.tools.length === 0
? `Server ${value.server} odpověděl, ale žádný nástroj nenabízí.`
: `Server ${value.server} nabízí nástrojů: ${value.tools.length}.`,
);
}
/** Zavola jeden nastroj. `args` uz musi byt v typech, ktere schema chce. */
export function callTool(
target: ResolvedTarget,
name: string,
args: Record<string, unknown>,
): Promise<McpOutcome<McpCallResult>> {
return attempt(
target,
async (signal) => {
const { session } = await openSession(target, signal);
const result = (await rpc(session, 'tools/call', { name, arguments: args }, signal)) as {
content?: Array<Record<string, unknown>>;
structuredContent?: Record<string, unknown>;
isError?: boolean;
};
const text = (result?.content ?? [])
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
.map((block) => String(block.text))
.join('\n');
const call: McpCallResult = {
text,
structured:
result?.structuredContent !== null && typeof result?.structuredContent === 'object'
? (result.structuredContent ?? null)
: null,
isError: result?.isError === true,
};
return call;
},
(value) => (value.isError ? `Nástroj ${name} skončil chybou.` : `Nástroj ${name} doběhl.`),
);
}
+281
View File
@@ -0,0 +1,281 @@
/**
* Prevod mezi JSON Schema nastroje a poli kroku.
*
* MCP server popisuje kazdy nastroj schematem: co prijima (`inputSchema`)
* a nekdy i co vraci (`outputSchema`). Builder umi jen ploche pole typu
* `OperationField`, kde je **hodnota vzdy retezec** (je to sablona s
* `{{promennymi}}`). Tenhle soubor to prevadi obema smery:
*
* - `fieldsFromSchema` udela ze schematu pole, ktera clovek v builderu vyplni,
* - `argumentsFrom` udela z vyplnenych retezcu argumenty ve spravnych typech.
*
* Ten druhy smer je ten podstatny. Server ceka `{"limit": 10}`, ne
* `{"limit": "10"}`, a rada serveru na tom spadne az uvnitr nastroje, kde uz
* neni poznat, co se stalo.
*
* Prochazi se **jen prvni uroven** schematu. Zanoreny objekt je jedno pole
* typu JSON: rozpadat ho na `adresa.ulice` by znamenalo vymyslet si jmena,
* ktera server nezna, a u pole se seznamem by to neslo vubec.
*/
import type { ProvidedField, OperationField } from '../data/services.js';
import type { FieldType } from '../data/conditions.js';
import type { JsonSchema, McpTool } from './client.js';
/**
* Vystupy, ktere ma **kazdy** nastroj bez ohledu na schema.
*
* MCP vraci vzdy bloky obsahu a priznak chyby. `structuredContent` ma jen
* nastroj, ktery deklaroval `outputSchema`, ale samotne pole je vzdy, jen
* byva prazdne.
*/
export const alwaysOutputs: ProvidedField[] = [
{ id: 'text', name: 'Textová odpověď', type: 'string', required: true },
{ id: 'isError', name: 'Nástroj hlásí chybu', type: 'boolean', required: true },
{ id: 'structured', name: 'Strukturovaná odpověď', type: 'object', required: false },
];
const alwaysNames = new Set(alwaysOutputs.map((field) => field.id));
/** Vlastnosti prvni urovne schematu. Prazdne, kdyz to objekt s vlastnostmi neni. */
function propertiesOf(schema: JsonSchema | undefined): Array<[string, JsonSchema]> {
const properties = schema?.properties;
if (properties === null || typeof properties !== 'object') return [];
return Object.entries(properties as Record<string, unknown>)
.filter(([, value]) => value !== null && typeof value === 'object')
.map(([key, value]) => [key, value as JsonSchema]);
}
function requiredOf(schema: JsonSchema | undefined): Set<string> {
const required = schema?.required;
return new Set(Array.isArray(required) ? required.filter((item) => typeof item === 'string') : []);
}
/**
* Typ vlastnosti.
*
* `type` smi byt i seznam (`["string", "null"]`) - to je zpusob, jakym se
* v JSON Schema zapisuje nepovinna hodnota. Bere se prvni, ktery neni `null`,
* protoze prave ten rika, co se ma vyplnit.
*/
function typeOf(property: JsonSchema): string {
const type = property.type;
if (typeof type === 'string') return type;
if (Array.isArray(type)) {
const first = type.find((item) => typeof item === 'string' && item !== 'null');
if (typeof first === 'string') return first;
}
// Bez typu, ale s vyctem hodnot: je to vyber.
if (Array.isArray(property.enum)) return 'string';
return 'unknown';
}
function enumOf(property: JsonSchema): string[] | null {
if (!Array.isArray(property.enum)) return null;
const values = property.enum
.filter((item) => item !== null && typeof item !== 'object')
.map((item) => String(item));
return values.length > 0 ? values : null;
}
/** Napoveda pod polem: popis od serveru plus to, co se z nej da vycíst. */
function hintFor(name: string, property: JsonSchema, required: boolean, type: string): string {
const parts: string[] = [];
if (typeof property.description === 'string' && property.description.trim() !== '') {
parts.push(property.description.trim());
}
if (type === 'integer') parts.push('Celé číslo.');
else if (type === 'number') parts.push('Číslo.');
else if (type === 'array') parts.push('Seznam zapsaný jako JSON, například ["a", "b"].');
else if (type === 'object') parts.push('Objekt zapsaný jako JSON.');
if (property.default !== undefined) {
parts.push(`Když necháte prázdné, server použije ${JSON.stringify(property.default)}.`);
} else if (!required) {
parts.push('Nepovinné, prázdné pole se serveru vůbec nepošle.');
}
// Jmeno v protokolu, aby slo dohledat, co se vlastne posila.
parts.push(`Parametr ${name}.`);
return parts.join(' ');
}
/**
* Pole kroku podle vstupniho schematu nastroje.
*
* Poradi je poradi ze schematu, jen povinna jdou napred - clovek pak vidi
* shora dolu to, bez ceho to nepujde.
*/
export function fieldsFromSchema(schema: JsonSchema | undefined): OperationField[] {
const required = requiredOf(schema);
const fields = propertiesOf(schema).map(([name, property]): OperationField => {
const type = typeOf(property);
const isRequired = required.has(name);
const label =
typeof property.title === 'string' && property.title.trim() !== '' ? property.title : name;
const hint = hintFor(name, property, isRequired, type);
const choices = enumOf(property);
if (choices) {
return {
id: name,
label,
kind: 'choice',
required: isRequired,
// Prazdna volba jen u nepovinneho, jinak by sla ulozit prazdna hodnota.
options: [
...(isRequired ? [] : [{ value: '', label: '- nevyplněno -' }]),
...choices.map((value) => ({ value, label: value })),
],
hint,
};
}
if (type === 'boolean') {
return {
id: name,
label,
kind: 'choice',
required: isRequired,
options: [
...(isRequired ? [] : [{ value: '', label: '- nevyplněno -' }]),
{ value: 'true', label: 'Ano' },
{ value: 'false', label: 'Ne' },
],
hint,
};
}
if (type === 'object' || type === 'array') {
return { id: name, label, kind: 'json', required: isRequired, hint };
}
return { id: name, label, kind: 'text', required: isRequired, hint };
});
return [...fields.filter((field) => field.required), ...fields.filter((field) => !field.required)];
}
/** Typ pole pro podminky. Seznam je `list`, zbytek se mapuje primo. */
function fieldType(type: string): FieldType {
if (type === 'number' || type === 'integer') return 'number';
if (type === 'boolean') return 'boolean';
if (type === 'array') return 'list';
if (type === 'object') return 'object';
return 'string';
}
/**
* Vystupy nastroje.
*
* Vzdy tri spolecne, k tomu vlastnosti z `outputSchema`, kdyz ho nastroj ma.
* Kolize jmena se resi ve prospech spolecnych: `text` znamena text odpovedi
* vzdycky, at uz si nastroj rika co chce. Nastroj se stejnojmennou vlastnosti
* je k dispozici pod `structured`.
*/
export function outputsFromTool(tool: McpTool): ProvidedField[] {
const required = requiredOf(tool.outputSchema);
const own = propertiesOf(tool.outputSchema)
.filter(([name]) => !alwaysNames.has(name))
.map(([name, property]): ProvidedField => ({
id: name,
name:
typeof property.title === 'string' && property.title.trim() !== '' ? property.title : name,
type: fieldType(typeOf(property)),
required: required.has(name),
}));
return [...alwaysOutputs, ...own];
}
export interface ArgumentsResult {
args: Record<string, unknown>;
/** Co se nepovedlo prevest. Prazdne = da se volat. */
issues: string[];
}
/** Zaskrtnuto, nebo ne? Ve strome se vsechno predava jako text. */
function toBoolean(value: string): boolean | null {
const text = value.trim().toLowerCase();
if (['true', '1', 'ano', 'yes'].includes(text)) return true;
if (['false', '0', 'ne', 'no'].includes(text)) return false;
return null;
}
/**
* Argumenty pro `tools/call` z toho, co clovek vyplnil v builderu.
*
* Prazdne nepovinne pole se **vynechava**, ne posila jako prazdny retezec.
* Server ma pro nevyplnenou hodnotu vlastni vychozi chovani a prazdny retezec
* neni totez jako "nevyplneno" - typicky by pretisknul vychozi hodnotu.
*
* Chyby se sbiraji vsechny najednou. Opravovat po jedne a pokazde spustit beh
* znovu je presne to, co nikdo nedela.
*/
export function argumentsFrom(
schema: JsonSchema | undefined,
inputs: Record<string, string>,
): ArgumentsResult {
const required = requiredOf(schema);
const args: Record<string, unknown> = {};
const issues: string[] = [];
for (const [name, property] of propertiesOf(schema)) {
const raw = (inputs[name] ?? '').trim();
const type = typeOf(property);
if (raw === '') {
if (required.has(name)) issues.push(`${name}: povinný parametr není vyplněný`);
continue;
}
if (type === 'number' || type === 'integer') {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
issues.push(`${name}: "${raw}" není číslo`);
continue;
}
if (type === 'integer' && !Number.isInteger(parsed)) {
issues.push(`${name}: "${raw}" není celé číslo`);
continue;
}
args[name] = parsed;
continue;
}
if (type === 'boolean') {
const parsed = toBoolean(raw);
if (parsed === null) {
issues.push(`${name}: "${raw}" není ano ani ne`);
continue;
}
args[name] = parsed;
continue;
}
if (type === 'object' || type === 'array') {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
issues.push(`${name}: není platný JSON`);
continue;
}
// Seznam zapsany jako objekt server odmitne az uvnitr nastroje.
if (type === 'array' && !Array.isArray(parsed)) {
issues.push(`${name}: má to být seznam, tedy [...]`);
continue;
}
if (type === 'object' && (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))) {
issues.push(`${name}: má to být objekt, tedy {...}`);
continue;
}
args[name] = parsed;
continue;
}
args[name] = raw;
}
return { args, issues };
}
+57
View File
@@ -1785,6 +1785,63 @@ export function buildOpenApiDocument() {
},
},
},
'/api/dashboard/connectors/{id}/mcp/tools': {
post: {
tags: ['Konektory'],
summary: 'Nacist nastroje MCP serveru',
description:
'Zepta se MCP serveru na tools/list a ulozi vysledek ke konektoru. Je to jedina ' +
'sluzba, u ktere seznam operaci neurcuje katalog, ale az sam server - teprve tim ' +
'vzniknou kroky, ktere jde davat do automatizaci, vcetne toho, jake promenne ' +
'prijimaji a jake vraceji. Zaroven to je overeni konektoru, proto se zapisuje do ' +
'historie: kdyz server odpovi seznamem, adresa i token sedi. Cteci volani, nic ' +
'nemeni. Prazdny vysledek se ulozi (server uz nastroje nenabizi), chyba nemeni nic ' +
'- vypadek serveru nesmi vymazat kroky z hotovych automatizaci. Neuspech neni ' +
'chyba API, vraci se 200 s ok: false.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
'200': {
description: 'Vysledek nacteni',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ok: { type: 'boolean' },
checked: { type: 'string' },
message: { type: 'string' },
status: { type: 'integer' },
detail: { type: 'string' },
server: { type: 'string', description: 'Jak se server predstavil.' },
protocolVersion: { type: 'string' },
tools: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
label: { type: 'string' },
description: { type: 'string' },
inputs: {
type: 'array',
items: { type: 'string' },
description: 'Nazvy parametru, povinne s hvezdickou na konci.',
},
outputs: { type: 'array', items: { type: 'string' } },
},
},
},
},
},
},
},
},
'400': { description: 'Sluzba neni MCP server' },
'404': { description: 'Konektor neexistuje' },
},
},
},
'/api/dashboard/connectors/{id}/checks': {
get: {
tags: ['Konektory'],
+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 })),
+116
View File
@@ -10,6 +10,11 @@
*/
import { defaultConnectorFor, getConnector } from '../data/connectorStore.js';
import { findMcpTool, parseOperationId } from '../data/mcpTools.js';
import { MCP_SERVICE_ID } from '../data/services.js';
import { callTool } from '../mcp/client.js';
import { argumentsFrom } from '../mcp/schema.js';
import { truncate } from '../scripts/util.js';
import { createIncident } from '../data/incidentStore.js';
import { sendMail } from '../mail/smtp.js';
import { resolveTarget } from '../scripts/connections.js';
@@ -43,6 +48,14 @@ export interface StepContext {
* uloziste. E-mail ano: odesila se ze schranky firmy.
*/
connectorId?: string | null;
/**
* Operace, ktera se vykonava.
*
* Vetsina kroku ji nepotrebuje - kazdy ma svoji obsluhu a ta vi, co dela.
* MCP ano: vsechny nastroje vsech serveru obsluhuje jedna funkce a teprve
* z ID operace pozna, ktery nastroj na kterem napojeni ma zavolat.
*/
operationId?: string;
/**
* Data, kterymi beh zacal - u webhooku cele prijate telo.
*
@@ -694,7 +707,110 @@ function safeJson(text: string): Record<string, unknown> {
}
}
/**
* Zavola nastroj na MCP serveru firmy.
*
* Jedna obsluha pro vsechny nastroje vsech serveru. Ktery to je, rika az ID
* operace (`tool:<konektor>:<nastroj>`) - jinak by musel existovat kus kodu na
* kazdy nastroj, ktery si firma zalozi, a to je presne to, co MCP resi.
*
* Krok se **neopakuje**. MCP nema idempotencni klic, takze druhy pokus po
* timeoutu by nastroj provedl podruhe - a jestli to znamena druhou objednavku,
* vi jen server, ktery neni nas.
*/
async function runMcpTool(
inputs: Record<string, string>,
context: StepContext,
): Promise<StepOutcome> {
const parsed = parseOperationId(context.operationId ?? '');
if (!parsed) {
return {
ok: false,
summary: 'neplatné ID nástroje',
detail: `Operace ${context.operationId ?? '(chybí)'} neodpovídá tvaru tool:<konektor>:<nástroj>.`,
outputs: {},
};
}
const found = findMcpTool(parsed.connectorId, parsed.toolName);
if (!found) {
return {
ok: false,
summary: 'nástroj není načtený',
detail:
`Nástroj ${parsed.toolName} u napojení ${parsed.connectorId} portál nezná. ` +
'Server ho možná přestal nabízet. Otevřete konektor a stiskněte Načíst nástroje.',
outputs: {},
};
}
// Cizi napojeni se chova jako neexistujici. Tohle je misto, kde se hlida,
// ze firma nezavola server jine firmy, i kdyby mela ve stromu jeho ID.
const connector = await getConnector(parsed.connectorId, [context.tenantId]);
if (!connector) {
return {
ok: false,
summary: 'napojení neexistuje',
detail: `Konektor ${parsed.connectorId} v této firmě není.`,
outputs: {},
};
}
const target = resolveTarget(connector.serviceId, connector);
if (!target.ready) {
return {
ok: false,
summary: 'napojení není hotové',
detail: target.missing.join(', '),
outputs: {},
};
}
/*
* Prevod na typy ze schematu. Ve strome je vsechno retezec, protoze je to
* sablona, ale server ceka `{"limit": 10}`, ne `{"limit": "10"}`. Rada
* serveru na tom spadne az uvnitr nastroje, kde uz neni poznat, co se stalo.
*/
const { args, issues } = argumentsFrom(found.tool.inputSchema, inputs);
if (issues.length > 0) {
return {
ok: false,
summary: 'parametry nesedí na schéma nástroje',
detail: issues.join('; '),
outputs: {},
};
}
const outcome = await callTool(target, parsed.toolName, args);
if (!outcome.ok || !outcome.value) {
return { ok: false, summary: outcome.message, detail: outcome.detail, outputs: {} };
}
const value = outcome.value;
return {
ok: !value.isError,
summary: value.isError
? `nástroj ${parsed.toolName} skončil chybou`
: `nástroj ${parsed.toolName} doběhl`,
detail: value.text === '' ? null : truncate(value.text, 600),
/*
* Strukturovana odpoved se rozbaluje do vystupu, aby na ni sla postavit
* podminka bez psani cesty. Spolecne tri hodnoty se pisou az po ni: `text`
* znamena text odpovedi vzdycky, at uz si nastroj rika co chce.
*/
outputs: {
...(value.structured ?? {}),
text: value.text,
isError: value.isError,
structured: value.structured,
},
};
}
/** Ma tenhle krok vlastni obsluhu? */
export function findBuiltinStep(serviceId: string, operationId: string): Handler | undefined {
// MCP nema pevny seznam operaci, nastroje rekne az server. Klic by tedy
// nebylo podle ceho slozit - obsluha je jedna a nastroj si najde sama.
if (serviceId === MCP_SERVICE_ID) return runMcpTool;
return handlers[`${serviceId}/${operationId}`];
}
+3
View File
@@ -326,6 +326,9 @@ async function runAction(
const outcome = await builtin(inputsForStep, {
tenantId: options.tenantId,
ticketId: options.ticketId,
// Nastroje MCP obsluhuje jedna funkce pro vsechny, takze potrebuje
// vedet, ktery krok to vlastne je.
operationId: step.operationId,
// Vetsina vnitrnich kroku napojeni nepotrebuje. Odeslani e-mailu ano:
// posila se ze schranky firmy, tedy pod jejim konektorem.
connectorId: step.connectorId ?? null,