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
+159
View File
@@ -0,0 +1,159 @@
# 00 - Pro programatory
Rozcestnik a duvody. Ostatni dokumenty popisuji, **co** aplikace dela, tenhle
popisuje, **jak je postavena a proc tak**.
## Nez zacnete
| Poradi | Precist | Proc |
| ------ | ---------------------------------------------------------------- | -------------------------------------------- |
| 1 | `AGENTS.md` v korenu | pravidla AppFactory, plati nad vsim |
| 2 | tenhle soubor | model a pravidla, ktera plati vsude |
| 3 | [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md) | kde co lezi |
| 4 | [15-rejstrik-funkci.md](15-rejstrik-funkci.md) | ktera funkce je na co, at se nepisou dvakrat |
| 5 | to, co se tyka ukolu | viz tabulka nize |
| Delam | Ctu |
| -------------------- | -------------------------------------------------------------------------------------------------------- |
| sluzbu nebo konektor | [12-sluzby-a-konektory.md](12-sluzby-a-konektory.md), [21-realne-sluzby.md](21-realne-sluzby.md) |
| skript sluzby | [11-skripty-konektoru.md](11-skripty-konektoru.md) |
| krok automatizace | [05-dashboard-a-builder.md](05-dashboard-a-builder.md), [20-fronta-a-runtime.md](20-fronta-a-runtime.md) |
| tickety a helpdesk | [06-tickety.md](06-tickety.md), [18-ticketovaci-system.md](18-ticketovaci-system.md) |
| prava a firmy | [07-firmy-a-prava.md](07-firmy-a-prava.md), [17-nastaveni-a-prava.md](17-nastaveni-a-prava.md) |
| uloziste | [14-databaze.md](14-databaze.md) |
| texty a jazyky | [23-jazyky.md](23-jazyky.md) |
| vzhled | [22-znacka-a-design.md](22-znacka-a-design.md) |
## Model, ktery je potreba mit v hlave
Ctyri pojmy. Plete se to a z te zamneny vznikaji nejhorsi chyby.
| Pojem | Co to je | Ciji je |
| ------------ | ----------------------------------------------- | ------------ |
| **Sluzba** | co umime: iDoklad, e-mail, MCP server, webhook | nase |
| **Skript** | vykonna cast operace, obycejny JS ve `scripts/` | nase |
| **Konektor** | pristupove udaje jedne firmy k jedne sluzbe | firmy |
| **Krok** | jedno pouziti operace v automatizaci | automatizace |
Sluzba rika "iDoklad potrebuje `X-ClientId` a `X-ClientSecret`". Konektor rika
"a tohle jsou nase". Skript rika `GET /issued-invoices/12` a **k udajum se
nedostane** - runtime je doplni az do requestu.
Diky tomu volaji dve firmy tutez sluzbu kazda pod svym uctem a v kodu neni ani
jeden pristupovy udaj.
## Pravidla, ktera plati vsude
**Filtr na firmu je povinny argument.** `listTickets(tenantIds)`,
`getConnector(id, tenantIds)`, `listConnectors(tenantIds)`. Zapomenuty filtr tak
neznamena "vse", ale nezkompiluje se. Cizi zaznam se chova jako neexistujici
(`undefined`), ne jako chyba prava - z odpovedi nema byt poznat, ze existuje.
**Tajna hodnota se nikdy nevraci z API.** `toPublicConnector` je jedine misto,
kde se konektor prevadi pro klienta, prave proto, aby se na to neslo zapomenout
na druhem miste. Do logu jde vsechno pres `createRedactor`, a to **v obou
tvarech** - `Bearer abc` i holé `abc`, protoze cizi sluzby vraceji v chybach
jednou to a jednou ono.
**Zadna ticha selhani.** Kazdy `catch` loguje a uzivatel se o chybe dozvi.
Chyba cizi sluzby neni chyba API: vraci se 200 s `ok: false` a **celym telem
odpovedi**, protoze prave tam je napsane, co jí vadilo. "HTTP 403" samo o sobe
nikoho nikam nedovede.
**Katalog je zdroj pravdy.** Co neni v `src/data/services.ts`, to nejde ulozit
do stromu. Validace pri ukladani se pta katalogu, ne klienta.
**Ve strome je vsechno retezec.** Hodnota kroku je sablona (`{{subject}}`),
takze i cislo je text. Prevod na skutecny typ se dela az pred volanim, u toho,
kdo vi, jaky typ to ma byt.
**Prava se nedovozuji na klientovi.** Server vraci `GET /api/dashboard/access`
s tim, co uzivatel smi. Dvoji vypocet se jednou rozejde.
**Prava plati za firmu, ne za cloveka.** `permissionsOf(user, tenantId)` ma
firmu jako povinny argument. Clovek muze byt spravce v jedne firme a bezny
uzivatel v druhe.
## Jak vznika operace, kterou clovek vybere v builderu
Tri cesty, kazda ma svuj duvod:
| Cesta | Kdy | Kde |
| -------------------- | --------------------------------------- | ----------------------------- |
| **Zapis v katalogu** | popis toho, co umime nebo budeme umet | `src/data/services.ts` |
| **Skript** | volani cizi sluzby pres HTTP | `scripts/*.js` + manifest |
| **Vnitrni krok** | sahá do naseho uloziste, nebo neni HTTP | `src/runtime/builtinSteps.ts` |
Runtime zkousi **nejdriv vnitrni krok, pak skript**. Kdyz operace nema ani
jedno, je to zatim jen zapis v katalogu a krok to rekne narovinu misto toho, aby
tise neudelal nic.
Vnitrni krok je pro zalozeni ticketu (nase uloziste), pro e-mail (SMTP neni HTTP)
a pro nastroje MCP (JSON-RPC se sezenim). Skript ma jen `ctx.http` a to je
zamer: skript, ktery umi sahnout do uloziste, uz neni skript, ale druha
aplikace.
## Katalog, ktery se meni za behu
Sluzby jsou staticky zapis v modulu. Tri veci se do nej doplnuji az za behu
a kazda jinak:
| Co | Odkud | Klic |
| ------------------ | --------------------- | -------------------- |
| operace ze skriptu | manifest skriptu | `setScriptActions` |
| nabidky v polich | uloziste (lide, typy) | `withRuntimeOptions` |
| nastroje MCP | cizi server | `setMcpOperations` |
Prvni dve jsou nase a spolecne vsem firmam. Treti je jina: **nastroj MCP patri
jedne firme**, protoze ho vystavuje jeji server. Proto s sebou nese `tenantId`
a `serviceCatalog(tenantId)` bez firmy nevrati zadny. Zapomenuty argument tak
znamena "nic", ne "vsechno" - stejne pravidlo jako u filtru na firmu.
U MCP navic **ID operace nese ID konektoru** (`tool:con_a1b2:create_order`).
Firma muze mit dva servery a na obou nastroj `search`; kdyby ID bylo jen
`search`, krok by nemel jak rict, ktery z nich. U ostatnich sluzeb to nehrozi,
tam je operace vlastnost sluzby, ne napojeni.
## Uloziste ma tri rezimy
| Rezim | Kdy | Prezije |
| ---------- | ----------------------------------- | -------------------- |
| `postgres` | je `DATABASE_URL` i `SECRETS_KEY` | vse |
| `file` | neni databaze, ale je datova slozka | restart, ne redeploy |
| `memory` | ani jedno | nic |
**Rozhodnuti je na jednom miste** (`connectorStore.ts`). Kdyby se rozlezlo po
kodu, jedno misto by se zapomnelo a chovalo by se pak jinak nez zbytek.
Nasazeni bezi v rezimu `file`. Ma to dusledek, ktery je poznat az pri zatezi:
**kazda zmena prepisuje celou kolekci** jako formatovany JSON. U ticketu, ktere
nesou i log prubehu, to s poctem zaznamu roste. Az bude portal pomaly, tohle je
prvni misto, kam se divat.
## Co je krehke
| Misto | Co hrozi |
| ---------------------------------- | ----------------------------------------------------------------------------------------------- |
| ID operaci a poli v katalogu | odkazuji se na ne ulozene stromy, prejmenovani je rozbije |
| poradi v `bootstrapData` | tickety az po resitelich, nastroje MCP az po firmach |
| overeni konektoru bez `verifyPath` | proxy vraci 200 s prazdnym telem i pro neexistujici aplikaci, takze test projde a nic to nerika |
| migrace | nikdy se neupravuji zpetne, oprava je vzdy novy soubor |
| BOM v JSONu | rozbije Node i Vite, zapisovat UTF-8 bez BOM |
## Kdyz neco pridavate
**Novou sluzbu** popiste v katalogu vcetne `credentials` a `verifyPath`. Bez
`verifyPath` overeni konektoru nerika nic o udajich, jen ze neco odpovida.
**Novy endpoint** patri do `src/openapi.ts`. Neni to volitelne, vyzaduje to
`AGENTS.md`, a nezdokumentovany endpoint neexistuje pro nikoho krome toho, kdo
ho napsal.
**Novy text v portalu** patri do `web/src/i18n/cs.ts` a klic do `en.ts`.
Anglictina je `Partial`, takze nemusi byt uplna - co chybi, spadne na cestinu.
**Novy sloupec** znamena novou migraci **a** doplneni obou uloziste,
souborového i databazoveho. Rozhrani je jedno, implementace dve.
Na konci prace se aktualizuje [01-prehled-a-stav.md](01-prehled-a-stav.md)
a [99-zmeny.md](99-zmeny.md).
+6
View File
@@ -39,6 +39,7 @@ React aplikaci ze slozky `dist/public`.
| Odesilani souboru ze skriptu | hotovo | `ctx.http.postForm`, obsah jako Base64 |
| OpenAI pod vlastnim klicem | hotovo | dotaz, soubor, prepis zvuku, seznam modelu |
| Konektory za firmu | hotovo | pristupove udaje v konektoru, overeni napojeni |
| MCP servery firmy | hotovo | nacte nastroje ze serveru, kazdy je krok automatizace |
| Transformace dat | hotovo | pravidla i sablona JSON, kroky si predavaji struktury |
| Prace nad celym modelem | hotovo | ukazka tela, cesty v sablonach, smycka nad seznamem |
| Vlastni skripty firmy | hotovo | prevod dat v JS, v logu vstup i vystup |
@@ -122,6 +123,7 @@ pro frontu, beh kroku a rozpocet na 150 klientu, a
| Soubor | O cem |
| ---------------------------------------------------------------- | ----------------------------------------------- |
| [00-pro-programatory.md](00-pro-programatory.md) | rozcestnik a duvody rozhodnuti |
| [02-appfactory-proxy.md](02-appfactory-proxy.md) | beh za reverse proxy, ROOT_PATH, health |
| [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md) | kde co je |
| [04-api.md](04-api.md) | endpointy a to, co ze Swaggeru neni videt |
@@ -141,4 +143,8 @@ pro frontu, beh kroku a rozpocet na 150 klientu, a
| [18-ticketovaci-system.md](18-ticketovaci-system.md) | udalosti, externi ID, statistiky, pohledy |
| [19-kapacita-200-firem.md](19-kapacita-200-firem.md) | zmereno, co zvladne soucasny stav |
| [20-fronta-a-runtime.md](20-fronta-a-runtime.md) | fronta, worker, spoustece, ochrana proti smycce |
| [21-realne-sluzby.md](21-realne-sluzby.md) | napojeni na bezici aplikace a e-mail |
| [22-znacka-a-design.md](22-znacka-a-design.md) | znacka WorkNuke, tokeny, prvky |
| [23-jazyky.md](23-jazyky.md) | prepinani jazyku a slovniky |
| [24-mcp-konektory.md](24-mcp-konektory.md) | MCP servery firmy jako kroky |
| [99-zmeny.md](99-zmeny.md) | zaznam zmen, nejnovejsi nahore |
+29 -22
View File
@@ -54,13 +54,19 @@ Adresu lze prepsat na dvou urovnich:
Nazev promenne vznikne z ID sluzby velkymi pismeny, pomlcka je podtrzitko:
`openai` je `OPENAI_BASE_URL`, `sap-bo` je `SAP_BO_BASE_URL`.
Treti pripad je sluzba, ktera **nejde pres HTTP**: e-mail. Ma
`transport: 'smtp'`, adresu serveru nese konektor mezi udaji a operaci nevykona
skript, ale vnitrni krok. Podrobnosti v [21-realne-sluzby.md](21-realne-sluzby.md).
Treti pripad jsou sluzby, ktere **nejdou pres HTTP tak jako zbytek**. Rika to
pole `transport`:
Treti pripad je sluzba, ktera **nejde pres HTTP**: e-mail. Ma
`transport: 'smtp'`, adresu serveru nese konektor mezi udaji a operaci nevykona
skript, ale vnitrni krok. Podrobnosti v [21-realne-sluzby.md](21-realne-sluzby.md).
| `transport` | Sluzba | Cim se lisi |
| ----------- | ---------- | -------------------------------------------------------------- |
| `http` | vetsina | vychozi, skript rekne cestu a runtime doplni adresu a hlavicky |
| `smtp` | E-mail | neni to HTTP, operaci vykona vnitrni krok |
| `mcp` | MCP server | JSON-RPC se sezenim, a hlavne **zadne operace v katalogu** |
U obou nevychozich nese adresu serveru **konektor mezi udaji**, ne pole "vlastni
adresa sluzby": sluzba zadnou vlastni adresu nema, protoze kazda firma ma svuj
server. Podrobnosti v [21-realne-sluzby.md](21-realne-sluzby.md)
a [24-mcp-konektory.md](24-mcp-konektory.md).
## Pristupove udaje patri konektoru, ne prostredi
@@ -193,11 +199,11 @@ Tak je na tom Prepis hovoru: jeho jedine volani je prepis, ktery se uctuje.
`verifyPath` smi nest i query (`/company?limit=1`), aby overeni nestahovalo
cely seznam. Do hlasky se query nedava, odrizne se - muze v ni byt tajemstvi.
U sluzby s `transport: 'smtp'` se `verifyPath` nepouziva vubec: overeni se
**prihlasi na posmovni server** a nic neodesle.
U sluzeb, ktere nejdou pres HTTP, se `verifyPath` nepouziva vubec:
U sluzby s `transport: 'smtp'` se `verifyPath` nepouziva vubec: overeni se
**prihlasi na posmovni server** a nic neodesle.
- `smtp` se **prihlasi na posmovni server** a nic neodesle,
- `mcp` si **rekne o seznam nastroju**, coz je jedina cteci operace, kterou
protokol ma.
Neuspesne overeni **neni chyba API**. Vraci se 200 s `ok: false` a popisem,
protoze vysledek "nefunguje to" je platna odpoved na otazku "funguje to?".
@@ -293,18 +299,19 @@ Cte se zvlast pres `/connectors/:id/checks`.
## API
| Metoda | Cesta | Popis |
| ------ | -------------------------------------- | ------------------------- |
| GET | `/api/dashboard/services` | katalog pro builder |
| GET | `/api/dashboard/connectors/services` | katalog ocima firmy |
| GET | `/api/dashboard/connectors` | konektory firmy |
| POST | `/api/dashboard/connectors` | zalozit |
| GET | `/api/dashboard/connectors/:id` | detail |
| PATCH | `/api/dashboard/connectors/:id` | upravit |
| DELETE | `/api/dashboard/connectors/:id` | smazat |
| POST | `/api/dashboard/connectors/:id/test` | overit napojeni |
| GET | `/api/dashboard/connectors/:id/checks` | poslednich pet overeni |
| GET | `/api/dashboard/connectors/egress-ip` | odchozi IP adresa portalu |
| Metoda | Cesta | Popis |
| ------ | ----------------------------------------- | --------------------------- |
| GET | `/api/dashboard/services` | katalog pro builder |
| GET | `/api/dashboard/connectors/services` | katalog ocima firmy |
| GET | `/api/dashboard/connectors` | konektory firmy |
| POST | `/api/dashboard/connectors` | zalozit |
| GET | `/api/dashboard/connectors/:id` | detail |
| PATCH | `/api/dashboard/connectors/:id` | upravit |
| DELETE | `/api/dashboard/connectors/:id` | smazat |
| POST | `/api/dashboard/connectors/:id/test` | overit napojeni |
| GET | `/api/dashboard/connectors/:id/checks` | poslednich pet overeni |
| POST | `/api/dashboard/connectors/:id/mcp/tools` | nacist nastroje MCP serveru |
| GET | `/api/dashboard/connectors/egress-ip` | odchozi IP adresa portalu |
## Stranky portalu
+146 -138
View File
@@ -1,161 +1,169 @@
# 24 - MCP konektory
Navrh, neni naprogramovane.
Hotovo. Firma si zalozi napojeni na svuj MCP server, stiskne **Nacist nastroje**
a jeho nastroje se objevi v builderu jako kroky automatizace.
Cil: firma vyplni udaje sveho MCP serveru a jeho nastroje muze pouzit model,
ktery za ni neco udela.
## Co MCP je
## Co MCP je a co neni
Protokol, kterym se zjisti, **co protistrana umi**, misto aby se to muselo
predem naprogramovat. Server vystavi nastroje, u kazdeho jmeno, popis a schema
toho, co prijima a co vraci. Klient si o ne rekne (`tools/list`) a pak je vola
(`tools/call`).
MCP je protokol **pro modely**. Server vystavuje nastroje s popisem a schematem
vstupu a *model* si sam vybere, ktery zavolat a s jakymi argumenty. Smysl je
v tom, ze se nastroj nemusi predem naprogramovat do postupu - model ho najde
a pouzije podle toho, co je zrovna potreba.
Bezne je klientem model - dostane nastroje jako sve schopnosti a sam si vybira,
ktery zavolat. **My jsme klientem taky, jen si nastroj vybira clovek v
builderu.** Prinos je tentyz: napojeni na cizi system vznikne bez radky kodu
u nas.
**Neni to protokol pro vzdalene volani procedur.** Kdyz nastroj vybere clovek
v builderu a vyplni mu pevna pole, MCP nedava nic navic proti HTTP konektoru,
ktery uz mame. Jen pribude vrstva a s ni JSON-RPC obalka.
## Jak to vypada
Z toho plyne jedina vec, na ktere cely navrh stoji:
1. Konektory, Novy konektor, sluzba **MCP server**.
2. Vyplni se adresa serveru a token.
3. Tlacitko **Nacist nastroje**. Portal se serveru zepta, co nabizi.
4. V builderu jsou nastroje jako kroky, s vlastnimi poli a vystupy.
> **MCP konektor neni zdroj kroku. Je to schopnost, kterou dostane krok
> s modelem.**
V builderu se tedy neobjevi polozka `create_invoice`. Objevi se krok
"Nechat model splnit ukol" a v nem se zaskrtne, ktera napojeni smi pouzit.
## Jak to vypada v kroku
```
Krok: Nechat model splnit ukol
Zadani: "Zaloz objednavku podle teto poptavky a vrat jeji cislo."
Data: {{data}}
Napojeni: [x] Interni sklad [ ] Firemni wiki
Model: gpt-4o-mini
```
Model dostane nastroje zaskrtnutych serveru, sam se rozhodne, ktere zavolat,
a krok vrati vysledek plus **seznam toho, co model opravdu udelal**.
Ten seznam neni pridavek. Cely tenhle system stoji na tom, ze je v logu videt,
co ktery krok provedl. Krok, ktery udela nekolik zapisu do ciziho systemu
a rekne jen "hotovo", je cerna skrinka a do provozu nepatri.
## Dve cesty, jak to postavit
Rozhodnuti, ktere je potreba udelat vedome: lisi se tim, kudy tece token
zakaznika.
### A) Server predame modelu
OpenAI Responses API umi vzit MCP server jako nastroj:
```json
{
"type": "mcp",
"server_label": "interni-sklad",
"server_url": "https://mcp.firma.cz",
"headers": { "Authorization": "Bearer ..." },
"allowed_tools": ["create_order", "get_stock"],
"require_approval": "never"
}
```
Malo prace: konektor drzi adresu a token, krok je slozi do pozadavku.
Cena za to:
- **Token zakaznika jde do OpenAI** a OpenAI vola jeho server primo. To musi
firma vedome odsouhlasit, ne se to dozvedet z dokumentace.
- Server musi byt dostupny **z internetu a ze site OpenAI**, ne jen z nasi.
Firemni server za VPN takhle nepouzijeme.
- Volani nejdou pres nas, takze **v nasem logu je jen to, co OpenAI vrati**,
a neplati na ne nase stropy ani redakce.
### B) MCP klientem jsme my
Sami se serveru zeptame na `tools/list`, nastroje predame modelu jako obycejne
funkce (function calling), a kdyz si model o volani rekne, **zavolame ho my**
a vysledek mu vratime.
Vic prace, ale:
- Token zustava u nas, stejne jako u kazdeho jineho konektoru.
- Server staci, kdyz je dostupny **z nasi adresy** - plati na nej seznamy
povolenych IP, ktere uz kvuli ostatnim sluzbam resime.
- Kazde volani jde pres `ctx.http`, tedy pres nase stropy, timeouty, redakci
a zapis do logu behu.
- Funguje s jakymkoliv modelem, ktery umi function calling, ne jen s OpenAI.
**Doporucuju B.** Ne proto, ze je hezci, ale protoze zbytek systemu stoji na
tom, ze pristupove udaje neopousti server a ze je v logu videt kazde volani.
Varianta A obe tahle pravidla porusuje, a to u konektoru, ktery si zaklada
zakaznik.
Varianta A ma smysl jako zkratka pro verejne MCP servery bez tajemstvi.
Tlacitko **Nastroje** u konektoru pak ukaze, co server rekl: u kazdeho nastroje
popis, jake parametry prijima (povinne s hvezdickou) a jake hodnoty vraci.
## Co si firma vyplni
| Pole | K cemu |
| ----------------- | ----------------------------------------------------- |
| Nazev | co clovek uvidi u kroku, napr. "Interni sklad" |
| Adresa serveru | HTTPS adresa MCP endpointu |
| Token | tajny, posila se jako `Authorization: Bearer` |
| Vlastni hlavicka | pro servery, ktere chteji neco jineho nez Bearer |
| Povolene nastroje | prazdne = vsechny. Jinak vycet, ktery model smi videt |
| Pole | K cemu |
| ------------------- | -------------------------------------------------- |
| Adresa MCP serveru | cely endpoint, napr. `https://mcp.firma.cz/mcp` |
| Token | posila se jako `Authorization: Bearer` |
| API klic v hlavicce | pro servery, ktere misto tokenu chteji `X-API-Key` |
**Vycet povolenych nastroju je hlavni pojistka.** U bezneho kroku vybira
operaci clovek, tady vybira model. Server muze vystavovat i nastroje, ktere do
automatizace nepatri (mazani, sprava uctu), a model o nich nevi nic krome
popisu, ktery si napsal jejich autor. Co neni ve vyctu, model vubec neuvidi.
Adresa 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.
## Schvalovani
Vlastni jmeno hlavicky se zadat neda. Bud `Authorization: Bearer`, nebo
`X-API-Key` - to jsou dva zpusoby, kterymi se autorizuje drtiva vetsina
serveru. Zadat libovolnou hlavicku by znamenalo zmenu modelu pristupovych
udaju, kde jmeno hlavicky urcuje sluzba, ne konektor.
MCP zna rezim, kdy volani ceka na schvaleni cloveka. V automatizaci, ktera bezi
sama v noci, neni koho se zeptat, takze jsou dve poctive moznosti:
## Nacteni nastroju
| Rezim | Kdy |
| ------------------------- | ----------------------------------------------- |
| Bez schvalovani | vychozi. Pojistkou je vycet povolenych nastroju |
| Se schvalenim pres ticket | beh se zastavi, zalozi ticket a ceka na cloveka |
`POST /api/dashboard/connectors/{id}/mcp/tools`
Druhy rezim je vic prace, ale zapada do produktu: **delegovani na cloveka uz
umime** a je to presne ono - model dojde k mistu, kde si netroufa, a preda to
dal. Do prvni faze bych to nedaval.
Je to zaroven **overeni konektoru**, proto se zapisuje do historie: kdyz server
odpovi seznamem, adresa i token sedi. Nic to nemeni, da se to spustit kdykoliv.
Tlacitko "Overit" u MCP konektoru neni - delalo by presne tohle.
## Bezpecnost
Dve pravidla, ktera nejsou zrejma:
- **Adresa serveru nesmi mirit do vnitrni site** (`isPrivateHost`), stejne jako
u HTTP a SMTP. Vyplnuje ji firma.
- **Odpoved nastroje jde do logu behu** a prochazi redakci. Co si server pise
do odpovedi, neovlivnime.
- **Strop na velikost odpovedi** uz existuje a je tu potreba: nastroj muze
vratit cely dokument.
- **Idempotence nefunguje.** MCP zadny takovy pojem nema, takze druhy pokus po
timeoutu zavola nastroj podruhe. Krok s MCP se proto **neopakuje sam**.
- **Nastroj je zakaznikuv.** Nevidime do nej a neruceme za to, co udela.
V portalu to musi byt videt.
- **Model muze zavolat vic nastroju za sebou.** Strop na pocet volani v jednom
kroku je potreba, jinak jde spotreba i cas nahoru bez omezeni.
- **Prazdny vysledek se ulozi.** Server, ktery uz nastroj nenabizi, ma zmizet
i z katalogu.
- **Chyba nemeni nic.** Vypadek serveru nesmi vymazat kroky z automatizaci,
ktere uz nekdo postavil.
## Postup
## Jak se z nastroje stane krok
| Faze | Co |
| ---- | ------------------------------------------------------------------------ |
| 1 | Sluzba `mcp`, pole konektoru, overeni pres `tools/list`, ulozeni seznamu |
| 2 | Krok "Nechat model splnit ukol" s vyberem napojeni, varianta B |
| 3 | Zaznam volanych nastroju do logu behu a strop na pocet volani |
| 4 | Schvalovani pres ticket |
Prevod dela `src/mcp/schema.ts`, prochazi se **prvni uroven** vstupniho
schematu:
Prvni faze je uzitecna sama o sobe: firma si napojeni zalozi, overi a vidi,
jake nastroje server nabizi, jeste nez se da pouzit v kroku.
| Ve schematu | V builderu |
| ----------------- | -------------------------------- |
| `string` | radek textu |
| `enum` | vyber z hodnot |
| `boolean` | vyber Ano / Ne |
| `number` | radek textu, prevede se na cislo |
| `object`, `array` | pole na JSON |
## Co bych nedelal
Zanoreny objekt zustava jedno pole s JSONem. Rozpadat ho na `adresa.ulice` by
znamenalo vymyslet si jmena, ktera server nezna, a u seznamu by to neslo vubec.
- **Nedaval bych MCP nastroje do vyberu kroku.** To byla chyba prvni verze
tohohle navrhu. Kdyz nastroj vybira clovek, staci HTTP konektor - MCP je tam
jen vrstva navic.
- **Nezpristupnoval bych `resources` a `prompts`.** Server je umi vedle
nastroju, ale krok stromu ma neco udelat, ne cist dokumenty.
- **Nedelal bych z nas MCP server.** Je to opacny smer a jine rozhodnuti:
pustit cizi modely na nase tickety.
Zpatky je to ten podstatny smer: ve strome je **vsechno retezec**, protoze je to
sablona s `{{promennymi}}`, ale server ceka `{"limit": 10}`, ne `{"limit": "10"}`.
Prevod na typy ze schematu se deje az pred volanim. Rada serveru na tom jinak
spadne az uvnitr nastroje, kde uz neni poznat, co se stalo.
Nepovinne pole, ktere zustane prazdne, **se serveru vubec neposle**. Prazdny
retezec neni totez jako nevyplneno - pretisknul by vychozi hodnotu serveru.
## Co krok vraci
Vzdy tri hodnoty, at uz nastroj deklaruje cokoliv:
| Vystup | Co je to |
| ------------ | ----------------------------------------------------- |
| `text` | textova cast odpovedi |
| `isError` | nastroj rekl, ze se nepovedlo (neni to chyba spojeni) |
| `structured` | strukturovana cast, kdyz ji nastroj ma |
Kdyz nastroj deklaruje `outputSchema`, jsou k tomu jeho vlastni pole rozbalena
do vystupu, takze na ne jde postavit podminka bez psani cesty. Pri kolizi jmen
vyhravaji ty tri spolecne: `text` znamena text odpovedi vzdycky, at uz si
nastroj rika co chce. Vlastni pole toho jmena je porad v `structured`.
`outputSchema` je v MCP nepovinne a vetsina serveru ho nema. Pak je znamy jen
text odpovedi. Neni to nedodelek u nas.
## Bezpecnost a limity
- **Adresa nesmi mirit do vnitrni site.** Tataz kontrola jako u HTTP a SMTP,
vyplnuje ji firma.
- **Token neopousti server.** Do prohlizece se nevraci a v logu je zredigovany
vcetne tvaru bez slova `Bearer`.
- **Cizi napojeni se chova jako neexistujici.** Krok si konektor nacita pres
filtr na firmu, takze strom s cizim ID konektoru selze.
- **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.
- Plati stejny timeout a strop na velikost odpovedi jako u skriptu
(`SCRIPT_TIMEOUT_MS`, `SCRIPT_MAX_RESPONSE_BYTES`).
- `tools/list` se strankuje nejvys dvacetkrat. Server, ktery vraci porad tentyz
kurzor, by jinak cyklil donekonecna.
## Co se **nedela**
- **Nastroje se pri startu neobvolavaji.** Cte se to, co je ulozene
u konektoru. Obvolavat servery vsech firem kvuli startu aplikace by
znamenalo, ze jeden nedostupny server shodi katalog vsem.
- **Zmena udaju nastroje nemaze.** Jina adresa muze vratit jiny seznam, ale
dokud ho nekdo nenacte, jsou ty stare porad to jedine, co v ulozenych
automatizacich drzi kroky nazivu.
- **`resources` a `prompts` se nectou.** Server je umi vedle nastroju, ale krok
stromu ma neco udelat, ne cist dokumenty.
- **Nejsme MCP server.** Je to opacny smer a jine rozhodnuti: pustit cizi
modely na nase tickety.
## Kde to je
| Cast | Soubor |
| ------------------- | ------------------------------------------- |
| Protokol | `src/mcp/client.ts` |
| Prevod schemat | `src/mcp/schema.ts` |
| Nastroje v katalogu | `src/data/mcpTools.ts` |
| Sluzba `mcp` | `src/data/services.ts` |
| Nacteni nastroju | `src/routes/connectors.ts` |
| Vykonna cast kroku | `src/runtime/builtinSteps.ts`, `runMcpTool` |
| Ulozeni u konektoru | `src/data/connectors/*`, migrace `004` |
| Portal | `web/src/pages/dashboard/Connectors.tsx` |
## Co jeste chybi
| Chybi | Poznamka |
| ---------------------------- | -------------------------------------------------------------------- |
| Nastroje pro model | dnes vybira nastroj clovek. Predat je modelu je dalsi krok, viz nize |
| stdio a starsi SSE transport | umi se jen Streamable HTTP, tedy to, co delaji verejne servery |
| Schvalovani volani | server ho umi vyzadovat, my na to zatim neumime cekat |
| Vlastni jmeno hlavicky | jen `Authorization` a `X-API-Key` |
### Nastroje pro model
Az bude krok "nechat model splnit ukol", muze dostat nastroje MCP serveru jako
sve schopnosti a vybirat si sam. Dve cesty, lisi se tim, kudy tece token:
- **Server preda modelu OpenAI.** Malo prace (`{"type": "mcp", ...}` v Responses
API), ale token jde do OpenAI, ta vola server firmy primo, server musi byt
dostupny z jeji site a volani nejdou pres nas log.
- **Klientem zustaneme my.** Nastroje se modelu predaji jako obycejne funkce
a volame je my. Token zustava u nas, plati nase stropy a redakce, funguje to
s jakymkoliv modelem - a hlavne uz je to postavene, tenhle dokument je presne
o tom.
Druha cesta je uz z devadesati procent hotova. Prvni by znamenala poslat
zakaznikuv token treti strane, coz je proti tomu, jak je postaveny zbytek
systemu.
+56 -14
View File
@@ -2,24 +2,66 @@
Nejnovejsi nahore.
## 2026-08-28 - navrh MCP konektoru
## 2026-08-28 - MCP konektory hotove
Novy dokument [24-mcp-konektory.md](24-mcp-konektory.md). Neni to
naprogramovane, je to navrh.
Firma si zalozi napojeni na svuj MCP server, stiskne **Nacist nastroje** a jeho
nastroje se objevi v builderu jako kroky. Popis
v [24-mcp-konektory.md](24-mcp-konektory.md).
**Prvni verze navrhu byla postavena spatne** a je prepsana. Davala MCP nastroje
do vyberu kroku, tedy nastroj vybiral clovek a vyplnil mu pevna pole. Tak MCP
nedava nic navic proti HTTP konektoru, ktery uz mame - je to protokol pro
**modely**, kde si nastroj vybira model podle toho, co je zrovna potreba.
Navrh byl predtim dvakrat prepsany a stoji za to rict proc. Prvni verze davala
nastroje do vyberu kroku, ale popisovala MCP jako obycejny katalog vzdalenych
procedur - tak nedava nic navic proti HTTP konektoru. Druha verze to prehnala
opacnym smerem a chtela nastroje predavat jen modelu. Vysledne zadani je
uprostred: **klientem jsme my**, nastroj vybira clovek v builderu, a prinos je
v tom, ze napojeni na cizi system vznikne bez radky kodu u nas.
Spravne zadani: MCP konektor **neni zdroj kroku, je to schopnost, kterou dostane
krok s modelem**. V builderu se objevi krok "Nechat model splnit ukol" a v nem
se zaskrtne, ktera napojeni smi pouzit.
### Pribylo
Dokument popisuje dve cesty a lisi se tim, kudy tece token zakaznika: predat
server modelu (OpenAI ho zavola sam), nebo byt MCP klientem my. Doporucena je
druha, protoze zbytek systemu stoji na tom, ze udaje neopousti server a ze je
v logu videt kazde volani.
- **Sluzba `mcp`.** Jedina v katalogu, ktera nema zadne pevne operace - rekne je
az server. Udaje jsou 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 tlacitko "Overit" u MCP neni.
- **Klient protokolu** (`src/mcp/client.ts`): handshake, sezeni z hlavicky
odpovedi, odpoved jako JSON i jako SSE stream, strankovani nastroju.
- **Prevod schemat** (`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"}`, a rada serveru na tom spadne az uvnitr nastroje.
- **Nastroje u konektoru** (sloupec `mcp`, 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 - ktery to je, rika az ID operace `tool:<konektor>:<nastroj>`.
### Rozhodnuti, ktera stoji za zminku
- **Nastroj patri firme, ne katalogu.** `serviceCatalog(tenantId)` bez firmy
nevrati zadny nastroj. Zapomenuty argument tak znamena "nic", ne "vsechno" -
stejne pravidlo jako u filtru na firmu.
- **ID operace nese ID konektoru.** Firma muze mit dva servery a na obou nastroj
`search`.
- **Krok se neopakuje.** MCP nema idempotencni klic, druhy pokus po timeoutu by
nastroj provedl podruhe.
- **Chyba nemaze nastroje.** Vypadek serveru nesmi vymazat kroky z hotovych
automatizaci. Prazdny seznam se naopak ulozi.
- **Servery se pri startu neobvolavaji.** Jeden nedostupny by shodil katalog
vsem.
### Opraveno mimochodem
- `setStatus` v `connectors/postgres.ts` melo v `RETURNING` doslovny retezec
`${COLUMNS}` misto dosazeni. V rezimu `file`, ve kterem bezi nasazeni, se to
neprojevilo.
- Dva odstavce v [12-sluzby-a-konektory.md](12-sluzby-a-konektory.md) byly
dvakrat.
## 2026-08-28 - dokument pro programatory
Novy [00-pro-programatory.md](00-pro-programatory.md): rozcestnik, model ctyr
pojmu (sluzba, skript, konektor, krok), pravidla, ktera plati vsude, tri cesty
vzniku operace, tri rezimy uloziste a seznam mist, ktera jsou krehka.
Neni to kopie [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md).
Ta rika, kde co lezi, tenhle rika proc to tak je.
## 2026-08-28 - transformace maji svou kategorii, pribylo XML
+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,
+161 -7
View File
@@ -11,6 +11,7 @@ import {
Star,
Trash2,
Wifi,
Wrench,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
@@ -36,6 +37,7 @@ import type {
ServiceOverview,
ServiceWithUsage,
StorageStatus,
ToolsLoadResult,
} from '@/types/dashboard';
/**
@@ -325,6 +327,41 @@ function ConnectorCard({
const [test, setTest] = useState<ConnectorTestResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [logsOpen, setLogsOpen] = useState(false);
const [toolsOpen, setToolsOpen] = useState(false);
/**
* MCP se neoveruje jako ostatni sluzby.
*
* Jinde je overeni cteci volani navic, tady je to ta hlavni vec, kterou
* konektor umi: teprve tim se zjisti, jake nastroje server nabizi, a
* teprve pak jde postavit krok. Proto to neni "Overit", ale "Nacist nastroje".
*/
const isMcp = service?.transport === 'mcp';
async function handleLoadTools() {
setBusy(true);
setError(null);
try {
const result = await apiFetch<ToolsLoadResult>(
`/api/dashboard/connectors/${connector.id}/mcp/tools`,
{ method: 'POST' },
);
// Stejny tvar jako vysledek overeni, takze se ukaze stejnou cestou.
setTest({
ok: result.ok,
checked: result.checked,
message: result.message,
status: result.status,
detail: result.detail,
});
if (result.ok) setToolsOpen(true);
onChanged();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Načtení nástrojů se nepodařilo odeslat.');
} finally {
setBusy(false);
}
}
async function handleTest() {
setBusy(true);
@@ -379,8 +416,20 @@ function ConnectorCard({
<div className="mt-3 flex flex-wrap gap-1.5">
<StatusBadge connector={connector} />
{!connector.enabled && <Badge tone="warn">vypnutý</Badge>}
{connector.mcp && (
<Badge tone={connector.mcp.tools.length > 0 ? 'ok' : 'warn'}>
nástrojů: {connector.mcp.tools.length}
</Badge>
)}
</div>
{isMcp && !connector.mcp && connector.ready && (
<p className="mt-2 text-xs text-white/45">
Nástroje ještě nejsou načtené. Dokud se nenačtou, nejde ze serveru postavit žádný
krok automatizace.
</p>
)}
{connector.missing.length > 0 && service && (
<p className="mt-2 text-xs text-warn-400">
Chybí:{' '}
@@ -428,10 +477,26 @@ function ConnectorCard({
<Pencil className="size-3.5" />
Upravit
</Button>
<Button size="sm" variant="ghost" onClick={handleTest} disabled={busy}>
<Wifi className="size-3.5" />
{busy ? 'Ověřuji...' : 'Ověřit'}
</Button>
{isMcp ? (
<>
<Button size="sm" variant="ghost" onClick={handleLoadTools} disabled={busy}>
<Wifi className="size-3.5" />
{busy ? 'Načítám...' : 'Načíst nástroje'}
</Button>
{connector.mcp && connector.mcp.tools.length > 0 && (
<Button size="sm" variant="ghost" onClick={() => setToolsOpen(true)}>
<Wrench className="size-3.5" />
Nástroje
<span className="text-white/40">{connector.mcp.tools.length}</span>
</Button>
)}
</>
) : (
<Button size="sm" variant="ghost" onClick={handleTest} disabled={busy}>
<Wifi className="size-3.5" />
{busy ? 'Ověřuji...' : 'Ověřit'}
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => setLogsOpen(true)}>
<ScrollText className="size-3.5" />
Logy
@@ -451,10 +516,99 @@ function ConnectorCard({
open={logsOpen}
onClose={() => setLogsOpen(false)}
/>
<ConnectorTools
connector={connector}
open={toolsOpen}
onClose={() => setToolsOpen(false)}
/>
</article>
);
}
/**
* Nastroje, ktere MCP server nabizi.
*
* Ukazuje se tu **to same**, z ceho se stavi kroky v automatizaci - jmena poli
* i vystupu jsou prevedena tou samou funkci na serveru. Kdyby to byly dva
* prevody, portal by u konektoru ukazoval jina pole, nez jakymi se nastroj
* opravdu vola.
*
* Nenacita se: seznam uz je soucasti konektoru, protoze se uklada pri stisku
* Nacist nastroje. Tim je videt i to, jak stary ten seznam je.
*/
function ConnectorTools({
connector,
open,
onClose,
}: {
connector: Connector;
open: boolean;
onClose: () => void;
}) {
const toolset = connector.mcp;
return (
<Modal
open={open}
onClose={onClose}
title={`Nástroje: ${connector.name}`}
description="Co server nabízí. Každý nástroj je v automatizaci samostatný krok."
className="max-w-4xl"
>
{toolset && (
<p className="border-b border-ink-600/60 px-5 py-3 text-xs text-white/40">
{toolset.server}
<span className="text-white/25"> / protokol {toolset.protocolVersion}</span>
<span className="block">Načteno {formatDateTime(toolset.at)}</span>
</p>
)}
<div className="max-h-[70vh] space-y-3 overflow-y-auto p-5">
{!toolset && (
<p className="text-sm text-white/40">
Nástroje ještě nejsou načtené. Stiskněte Načíst nástroje.
</p>
)}
{toolset?.tools.length === 0 && (
<p className="text-sm text-white/40">Server odpověděl, ale žádný nástroj nenabízí.</p>
)}
{toolset?.tools.map((tool) => (
<div key={tool.name} className="rounded-lg border border-ink-600/60 p-3">
<p className="font-semibold text-white">
{tool.label}
{tool.label !== tool.name && (
<span className="ml-2 font-mono text-xs font-normal text-white/35">{tool.name}</span>
)}
</p>
{tool.description && (
<p className="mt-1 text-xs text-white/50">{tool.description}</p>
)}
<dl className="mt-2 grid gap-2 text-xs sm:grid-cols-2">
<div>
<dt className="text-white/35">Přijímá</dt>
<dd className="text-white/70">
{tool.inputs.length > 0 ? tool.inputs.join(', ') : 'nic'}
</dd>
</div>
<div>
<dt className="text-white/35">Vrací</dt>
<dd className="text-white/70">{tool.outputs.join(', ')}</dd>
</div>
</dl>
</div>
))}
</div>
{toolset && toolset.tools.length > 0 && (
<p className="border-t border-ink-600/60 px-5 py-3 text-xs text-white/35">
Hvězdička u parametru znamená povinný. Nepovinný parametr, který necháte prázdný, se
serveru vůbec nepošle.
</p>
)}
</Modal>
);
}
/**
* Poslednich pet overeni konektoru.
*
@@ -773,10 +927,10 @@ function ConnectorEditor({
)}
{/*
U SMTP nemá vlastní adresa co dělat: server, port i šifrování jsou
mezi údaji výše. Prázdné pole navíc by svádělo sem psát adresu znovu.
U SMTP i u MCP nese adresu konektor mezi udaji, ne tohle pole.
Prazdne pole navic by svadelo psat adresu podruhe.
*/}
{service?.transport !== 'smtp' && (
{(service?.transport ?? 'http') === 'http' && (
<Field
label="Vlastní adresa služby"
hint="Nechte prázdné, pokud nemáte vlastní instanci."
+38 -2
View File
@@ -319,8 +319,11 @@ export interface Service {
/** true = funguje bez konektoru (webhook, pauza, transformace dat). */
general: boolean;
appId: string | null;
/** `smtp` = sluzba se nevola pres HTTP, adresu nese konektor v udajich. */
transport?: 'http' | 'smtp';
/**
* `smtp` = sluzba se nevola pres HTTP, adresu nese konektor v udajich.
* `mcp` = totez, a navic zadne operace v katalogu nema - rekne je az server.
*/
transport?: 'http' | 'smtp' | 'mcp';
visibility: ServiceVisibility;
credentials: ServiceCredentialField[];
verifyPath?: string;
@@ -369,6 +372,39 @@ export interface Connector {
/** Necitliva nastaveni. Tajna pole tu nejsou vubec. */
config: Record<string, string>;
ready: boolean;
/** Nastroje MCP serveru. `null` u vsech ostatnich sluzeb. */
mcp: Toolset | null;
}
/** Co MCP server nabizi. Vznika stiskem Nacist nastroje. */
export interface Toolset {
/** ISO cas nacteni. Rika, jak stary seznam clovek vidi. */
at: string;
server: string;
protocolVersion: string;
tools: ToolSummary[];
}
export interface ToolSummary {
name: string;
label: string;
description: string;
/** Nazvy parametru. Povinne maji na konci hvezdicku. */
inputs: string[];
/** Nazvy hodnot, ktere nastroj vraci. */
outputs: string[];
}
/** Odpoved na nacteni nastroju. Neuspech je vysledek, ne chyba API. */
export interface ToolsLoadResult {
ok: boolean;
checked: string;
message: string;
status?: number;
detail?: string;
server?: string;
protocolVersion?: string;
tools: ToolSummary[];
}
/** Kam se uklada a co to prezije. */