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:
co-authored by
Claude Opus 5
parent
3e6b365eec
commit
435e254c90
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -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.`),
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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}`];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user