diff --git a/documentation/00-pro-programatory.md b/documentation/00-pro-programatory.md new file mode 100644 index 0000000..4fbbb1a --- /dev/null +++ b/documentation/00-pro-programatory.md @@ -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). diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index ffe43ed..ece1423 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.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 | diff --git a/documentation/12-sluzby-a-konektory.md b/documentation/12-sluzby-a-konektory.md index f426107..121f124 100644 --- a/documentation/12-sluzby-a-konektory.md +++ b/documentation/12-sluzby-a-konektory.md @@ -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 diff --git a/documentation/24-mcp-konektory.md b/documentation/24-mcp-konektory.md index 6b0a995..e188633 100644 --- a/documentation/24-mcp-konektory.md +++ b/documentation/24-mcp-konektory.md @@ -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. diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index e45d9b3..66c6a46 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -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::`. + +### 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 diff --git a/src/data/bootstrap.ts b/src/data/bootstrap.ts index 36a623b..491513f 100644 --- a/src/data/bootstrap.ts +++ b/src/data/bootstrap.ts @@ -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. diff --git a/src/data/connectorStore.ts b/src/data/connectorStore.ts index 6bc756a..7207a05 100644 --- a/src/data/connectorStore.ts +++ b/src/data/connectorStore.ts @@ -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 { + return repository.setTools(id, tools, tenantIds); +} + /** Vysledek overeni napojeni. Zapisuje ho endpoint pro test. */ export function setConnectorStatus( id: string, diff --git a/src/data/connectors/local.ts b/src/data/connectors/local.ts index 45d5d6b..1a3d4db 100644 --- a/src/data/connectors/local.ts +++ b/src/data/connectors/local.ts @@ -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, diff --git a/src/data/connectors/postgres.ts b/src/data/connectors/postgres.ts index cacc018..afdb5dd 100644 --- a/src/data/connectors/postgres.ts +++ b/src/data/connectors/postgres.ts @@ -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( + `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; + }, }; diff --git a/src/data/connectors/types.ts b/src/data/connectors/types.ts index 32c9d71..6ecd2dc 100644 --- a/src/data/connectors/types.ts +++ b/src/data/connectors/types.ts @@ -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; /** 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; + /** Ulozi nastroje MCP serveru. Vlastni metoda, aby se nemichaly s udaji. */ + setTools( + id: string, + tools: McpToolset | null, + tenantIds: string[], + ): Promise; } // ------------------------------------------------------------------- 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), + })), }; } diff --git a/src/data/mcpTools.ts b/src/data/mcpTools.ts new file mode 100644 index 0000000..28cecbb --- /dev/null +++ b/src/data/mcpTools.ts @@ -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(); + +/** + * 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 { + 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(); +} diff --git a/src/data/services.ts b/src/data/services.ts index cf300ad..0ccf9b1 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -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): 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; + }); } /** diff --git a/src/db/migrations/004_connector_mcp.sql b/src/db/migrations/004_connector_mcp.sql new file mode 100644 index 0000000..bc32d52 --- /dev/null +++ b/src/db/migrations/004_connector_mcp.sql @@ -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; diff --git a/src/mcp/client.ts b/src/mcp/client.ts new file mode 100644 index 0000000..0a1bd63 --- /dev/null +++ b/src/mcp/client.ts @@ -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; + +/** 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 | 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 { + 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; + /** 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 | undefined, + signal: AbortSignal, + expectResult = true, +): Promise { + 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; + 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( + target: ResolvedTarget, + run: (signal: AbortSignal) => Promise, + onSuccess: (value: T) => string, +): Promise> { + let request: McpOutcome['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> { + 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, +): Promise> { + 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>; + structuredContent?: Record; + 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.`), + ); +} diff --git a/src/mcp/schema.ts b/src/mcp/schema.ts new file mode 100644 index 0000000..fb29581 --- /dev/null +++ b/src/mcp/schema.ts @@ -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) + .filter(([, value]) => value !== null && typeof value === 'object') + .map(([key, value]) => [key, value as JsonSchema]); +} + +function requiredOf(schema: JsonSchema | undefined): Set { + 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; + /** 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, +): ArgumentsResult { + const required = requiredOf(schema); + const args: Record = {}; + 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 }; +} diff --git a/src/openapi.ts b/src/openapi.ts index f3003b0..756fa4b 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -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'], diff --git a/src/routes/connectors.ts b/src/routes/connectors.ts index f2224d2..38faa66 100644 --- a/src/routes/connectors.ts +++ b/src/routes/connectors.ts @@ -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(); @@ -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. * diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 3bf6ee5..b466515 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -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 })), diff --git a/src/runtime/builtinSteps.ts b/src/runtime/builtinSteps.ts index a9747fe..ed275c2 100644 --- a/src/runtime/builtinSteps.ts +++ b/src/runtime/builtinSteps.ts @@ -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 { } } +/** + * Zavola nastroj na MCP serveru firmy. + * + * Jedna obsluha pro vsechny nastroje vsech serveru. Ktery to je, rika az ID + * operace (`tool::`) - 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, + context: StepContext, +): Promise { + const parsed = parseOperationId(context.operationId ?? ''); + if (!parsed) { + return { + ok: false, + summary: 'neplatné ID nástroje', + detail: `Operace ${context.operationId ?? '(chybí)'} neodpovídá tvaru tool::.`, + 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}`]; } diff --git a/src/runtime/executor.ts b/src/runtime/executor.ts index 5460981..ba9a9da 100644 --- a/src/runtime/executor.ts +++ b/src/runtime/executor.ts @@ -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, diff --git a/web/src/pages/dashboard/Connectors.tsx b/web/src/pages/dashboard/Connectors.tsx index 847df9a..5b3055c 100644 --- a/web/src/pages/dashboard/Connectors.tsx +++ b/web/src/pages/dashboard/Connectors.tsx @@ -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(null); const [error, setError] = useState(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( + `/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({
{!connector.enabled && vypnutý} + {connector.mcp && ( + 0 ? 'ok' : 'warn'}> + nástrojů: {connector.mcp.tools.length} + + )}
+ {isMcp && !connector.mcp && connector.ready && ( +

+ Nástroje ještě nejsou načtené. Dokud se nenačtou, nejde ze serveru postavit žádný + krok automatizace. +

+ )} + {connector.missing.length > 0 && service && (

Chybí:{' '} @@ -428,10 +477,26 @@ function ConnectorCard({ Upravit - + {isMcp ? ( + <> + + {connector.mcp && connector.mcp.tools.length > 0 && ( + + )} + + ) : ( + + )}