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