diff --git a/documentation/00-pro-programatory.md b/documentation/00-pro-programatory.md index 4fbbb1a..683cd41 100644 --- a/documentation/00-pro-programatory.md +++ b/documentation/00-pro-programatory.md @@ -109,6 +109,9 @@ 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. +Sluzby s nastroji jsou dve (obecna a EasyWeb), takze se filtruje i podle +sluzby. Nastroje jednoho serveru nemaji co delat v katalogu toho druheho. + 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, diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index ece1423..a743ab4 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -39,7 +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 | +| MCP servery firmy | hotovo | dve sluzby: obecna podle specifikace a MCP EasyWebu | | 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 | diff --git a/documentation/12-sluzby-a-konektory.md b/documentation/12-sluzby-a-konektory.md index 121f124..ce14355 100644 --- a/documentation/12-sluzby-a-konektory.md +++ b/documentation/12-sluzby-a-konektory.md @@ -57,13 +57,17 @@ Nazev promenne vznikne z ID sluzby velkymi pismeny, pomlcka je podtrzitko: Treti pripad jsou sluzby, ktere **nejdou pres HTTP tak jako zbytek**. Rika to pole `transport`: -| `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** | +| `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, MCP EasyWeb | JSON-RPC se sezenim, a hlavne **zadne operace v katalogu** | -U obou nevychozich nese adresu serveru **konektor mezi udaji**, ne pole "vlastni +Sluzby s `transport: 'mcp'` jsou dve, protoze prihlaseni k MCP standardizovane +neni: obecna podle specifikace a EasyWeb s vlastnim prihlasenim. Podrobnosti +v [24-mcp-konektory.md](24-mcp-konektory.md). + +U vsech 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). diff --git a/documentation/24-mcp-konektory.md b/documentation/24-mcp-konektory.md index 3c7737e..3f967e0 100644 --- a/documentation/24-mcp-konektory.md +++ b/documentation/24-mcp-konektory.md @@ -1,6 +1,6 @@ # 24 - MCP konektory -Hotovo. Firma si zalozi napojeni na svuj MCP server, stiskne **Nacist nastroje** +Hotovo. Firma si zalozi napojeni na MCP server, stiskne **Nacist nastroje** a jeho nastroje se objevi v builderu jako kroky automatizace. ## Co MCP je @@ -11,14 +11,46 @@ toho, co prijima a co vraci. Klient si o ne rekne (`tools/list`) a pak je vola (`tools/call`). 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 +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. +## Dve sluzby, ne jedna + +| Sluzba | Pro co | +| --------------- | -------------------------------------------- | +| **MCP server** | libovolny server podle oficialni specifikace | +| **MCP EasyWeb** | server EasyWebu, tedy Centaur | + +MCP je standard, ale **prihlaseni k nemu ne**. Oficialni specifikace stoji na +OAuth 2.1 vcetne objevovani autorizacniho serveru pres `.well-known`. EasyWeb +ma prihlaseni vlastni: `POST /login` s HTTP Basic vrati trojici tokenu a ty se +obnovuji vlastnimi endpointy. Zadny OAuth, zadne `.well-known`. + +Proc dve sluzby a ne jedna s prepinacem: **firma vyplnuje neco jineho**. +U oficialni ID a tajemstvi aplikace nebo hotovy token, u EasyWebu jmeno, heslo +a nazev zarizeni. Hadat to z adresy nejde a nabidnout obojí najednou by +znamenalo formular, kde je pulka poli vzdycky k nicemu. + +Obecna sluzba pritom **zustava plnohodnotna**. Vlastni server je duvod pridat +sluzbu, ne duvod zavrit dvere ostatnim. + +Rozdily jsou na jednom miste v `src/mcp/dialect.ts`: + +| Vlastnost | MCP server | MCP EasyWeb | +| ------------------------- | ------------ | ------------ | +| prihlaseni | OAuth 2.1 | vlastni | +| verze protokolu | `2025-06-18` | `2025-11-25` | +| odpoved jako SSE stream | ano | ne | +| hlavicka `Mcp-Session-Id` | ano | ne | + +Verze protokolu neni kosmetika: EasyWeb si po handshaku kontroluje, ze hlavicka +`MCP-Protocol-Version` sedi na jeho konstantu, a jinou odmitne. + ## Jak to vypada -1. Konektory, Novy konektor, sluzba **MCP server**. -2. Vyplni se adresa serveru, jmeno a heslo. +1. Konektory, Novy konektor, sluzba **MCP server** nebo **MCP EasyWeb**. +2. Vyplni se adresa a prihlasovaci udaje. 3. Tlacitko **Nacist nastroje**. Portal se serveru zepta, co nabizi. 4. V builderu jsou nastroje jako kroky, s vlastnimi poli a vystupy. @@ -27,64 +59,87 @@ popis, jake parametry prijima (povinne s hvezdickou) a jake hodnoty vraci. ## Co si firma vyplni -Zakaznik dostane ke svemu serveru **adresu, jmeno a heslo**. Token nedostane -a nema jak ho ziskat - vyda ho az autorizacni server a ma omezenou zivotnost. -Obstarat ho, hlidat platnost a vcas ho obnovit je proto prace portalu. - -| Pole | K cemu | -| --------------------- | -------------------------------------------------------- | -| Adresa MCP serveru | cely endpoint, napr. `https://mcp.firma.cz/mcp` | -| Jmeno | jmeno nebo ID aplikace, prazdne u serveru bez prihlaseni | -| Heslo | heslo nebo tajny klic k tomu jmenu | -| Adresa pro prihlaseni | jen kdyz ji portal sam nenajde | -| Rozsah opravneni | jen kdyz ji provozovatel serveru rekl | - -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 +Adresa je u obou **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, kazda firma ma svuj server. Stejne to ma SMTP. +### MCP server + +| Pole | K cemu | +| --------------------- | ----------------------------------------------- | +| Adresa MCP serveru | cely endpoint, napr. `https://mcp.firma.cz/mcp` | +| Token | kdyz jste dostali hotovy token | +| ID aplikace | druha moznost: server ma OAuth | +| Tajemstvi aplikace | patri k ID aplikace | +| Adresa pro prihlaseni | jen kdyz ji portal sam nenajde | +| Rozsah opravneni | jen kdyz ji provozovatel rekl | + +Ctyri pole na prihlaseni vypadaji jako moc, ale kazde je jina realna situace. +Verejne servery vydavaji hotovy token a nic jineho neumi, firemni jedou na +OAuth. Kdyby slo jen jedno, cast serveru by nesla napojit vubec. + +### MCP EasyWeb + +| Pole | K cemu | +| ------------------ | ---------------------------------------------------- | +| Adresa MCP serveru | endpoint, napr. `https://web.firmy.cz/centaur/mcp` | +| Jmeno | uzivatel, pod kterym se portal hlasi | +| Heslo | portal si za nej sam vyzvedne pristup | +| Nazev zarizeni | pod timhle nazvem je prihlaseni videt v logu serveru | +| Otisk zarizeni | podle nej server pozna totez zarizeni | + +Token se nezadava a zadavat nejde. **Zakaznik dostane adresu, jmeno a heslo**, +token vydava az server a ma omezenou zivotnost. + +Prihlasovaci adresy si portal odvodi z adresy serveru sam: + +| Endpoint | K cemu | +| ----------------------------- | -------------------------------- | +| `{server}/login` | HTTP Basic, vrati trojici tokenu | +| `{server}/renew-access-token` | obnova pristupoveho tokenu | + +Otisk zarizeni se doplnuje z ID konektoru (`worknuke-con_abc123`), aby server +poznal, ze jde porad o totez zarizeni, a aby si dve napojeni tehoz portalu +nesahala do sezeni. + ## Prihlaseni a zivotnost tokenu -Cely zivotni cyklus tokenu resi `src/mcp/auth.ts`. Postup je vzdy stejny: - -1. **Kde se prihlasit.** Bud je adresa vyplnena u konektoru, nebo se zjisti od - serveru: `/.well-known/oauth-protected-resource` rekne, ktery autorizacni - server za nim stoji, a jeho metadata rikaji token endpoint. -2. **Cim se prihlasit.** Nejdriv `client_credentials`, tedy jmeno a heslo jako - identita aplikace. Kdyz to server odmitne, zkusi se `password`, tedy jmeno - a heslo jako uzivatel. Ktere z toho firma dostala, se z udaju samych poznat - neda a nutit ji to vybirat by znamenalo ptat se na neco, co nevi. -3. **Kdyz autorizacni server neni**, posle se HTTP Basic. Mensi servery zadny - OAuth nemaji a jmeno s heslem je u nich presne tohle. - -Ktera z cest to byla, se pise do hlasky u konektoru: uzivatel vyplnil jmeno -a heslo a ma vedet, jak s nimi portal nalozil, nez zacne hledat chybu jinde. - -Zivotnost urcuje server: +Cely zivotni cyklus resi `src/mcp/auth.ts`. **Token je kratkodoby, jeho +zivotnost urcuje server a hlidat ji je prace portalu.** | Situace | Co portal udela | | -------------------------------- | ---------------------------------------------- | | token plati | pouzije ho | | do vyprseni zbyva min nez minuta | vymeni ho driv, nez vyprsi behem volani | -| server poslal `refresh_token` | obnovi jim, je to levnejsi nez cele prihlaseni | -| `expires_in` server neuvedl | pocita s peti minutami, tedy odhaduje dolu | +| server vydal obnovovaci token | obnovi jim, je to levnejsi nez cele prihlaseni | +| obnova neprojde | prihlasi se cele znovu | | server token odmitne pres 401 | zahodi ho a zkusi to **jednou** znovu | -To posledni je na odebrana opravneni: token jeste neexpiroval, ale uz neplati. -Druhy pokus uz se nedela - to uz nejsou udaje, ktere by sedely. +Kdy token vyprsi, se zjistuje ze tri zdroju v tomhle poradi: `expires_in` +v sekundach, datum v odpovedi, a nakonec **`exp` z tela samotneho tokenu**. +To posledni je pro servery, ktere zivotnost nikam nepisou, ale vydavaji JWT - +a je to presne pripad EasyWebu. Kdyz neni ani jedno, pocita se s peti minutami, +tedy odhaduje se dolu. -**Token se drzi jen v pameti.** Je kratkodoby, takze po restartu se o novy rekne -znovu. Do souboru ani do tabulky nepatri: ulozit kratkodoby token je vsechna -rizika ulozeni bez jakekoliv vyhody. +Odmitnuty token je na odebrana opravneni: jeste neexpiroval, ale uz neplati. +Druhy pokus uz se nedela, to uz nejsou udaje, ktere by sedely. + +**Token se drzi jen v pameti.** Po restartu se o novy rekne znovu. Do souboru +ani do tabulky nepatri: ulozit kratkodoby token je vsechna rizika ulozeni bez +jakekoliv vyhody. Kes drzi otisk udaju, takze zmena hesla ulozeny token +zneplatni. + +Zpusob prihlaseni se pise do hlasky u konektoru. Uzivatel vyplnil udaje a ma +vedet, jak s nimi portal nalozil, nez zacne hledat chybu jinde. ## Nacteni nastroju `POST /api/dashboard/connectors/{id}/mcp/tools` Je to zaroven **overeni konektoru**, proto se zapisuje do historie: kdyz server -odpovi seznamem, adresa i prihlaseni sedi. Nic to nemeni, da se to spustit kdykoliv. -Tlacitko "Overit" u MCP konektoru neni - delalo by presne tohle. +odpovi seznamem, adresa i prihlaseni sedi. Nic to nemeni, da se to spustit +kdykoliv. Tlacitko "Overit" u MCP konektoru neni, delalo by presne tohle. Dve pravidla, ktera nejsou zrejma: @@ -117,6 +172,37 @@ 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. +## Strankovani nastroju + +Nastroj muze vracet data po strankach: v odpovedi je kurzor na dalsi a ten se +posle zpatky v argumentu. Vzorem je `db/search` v EasyWebu, ktery ma `limit` +a `cursor` a vraci `NextCursor`. + +Kurzor je hodnota z odpovedi, takze **v dobe stavby stromu ho nikdo nezna** +a nejde ho vyplnit dopredu. Nastroj, ktery ma parametr `cursor`, proto dostane +v builderu prepinac **Nacist vsechny stranky** navic. Zapnuty projde stranky za +sebou a vysledky spoji. + +K obvyklym vystupum pak pribydou: + +| Vystup | Co je to | +| ----------- | -------------------------------------------- | +| `items` | polozky ze vsech stranek za sebou | +| `pages` | jednotlive stranky tak, jak prisly | +| `pageCount` | kolik stranek se nacetlo | +| `truncated` | true = strop vycerpan a server nabizel dalsi | + +Pozna se to podle jmena, protoze JSON Schema nema jak rict "tohle je kurzor". +Je to dohoda, ne standard, takze se hleda **presne `cursor`** a nic jineho - +nastroj s parametrem `cursorColor` by jinak zacal delat neco jineho, nez co ma. +V odpovedi se prijima `nextCursor`, `next_cursor` i `cursor`, bez ohledu na +velikost pismen. + +Strop je **20 stranek na krok**. Rozbity server muze vracet porad tentyz kurzor +a bez stropu by krok bezel, dokud ho nezastavi timeout. Kdyz se strop vycerpa +a server porad nabizi dalsi, rekne se to v souhrnu kroku - tichy vysledek by +vypadal jako uplny. + ## Co krok vraci Vzdy tri hodnoty, at uz nastroj deklaruje cokoliv: @@ -140,10 +226,7 @@ text odpovedi. Neni to nedodelek u nas. - **Adresa nesmi mirit do vnitrni site.** Tataz kontrola jako u HTTP a SMTP, vyplnuje ji firma. - **Heslo ani token neopousti server.** Heslo se z API nevraci vubec, token - nikde nevznika jinde nez v pameti procesu. V logu jsou zredigovane oboje, - vcetne tvaru bez slova `Bearer`. -- **Zmena hesla zneplatni ulozeny token.** Kes si drzi otisk udaju, takze po - uprave konektoru se portal prihlasi znovu. + nikde nevznika jinde nez v pameti procesu. V logu jsou zredigovane oboje. - **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 @@ -151,8 +234,7 @@ text odpovedi. Neni to nedodelek u nas. 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. +- `tools/list` se strankuje nejvys dvacetkrat, stejne jako volani nastroje. ## Co se **nedela** @@ -162,8 +244,6 @@ text odpovedi. Neni to nedodelek u nas. - **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. @@ -171,11 +251,12 @@ text odpovedi. Neni to nedodelek u nas. | Cast | Soubor | | ------------------- | ------------------------------------------- | +| Rozdily serveru | `src/mcp/dialect.ts` | | Protokol | `src/mcp/client.ts` | | Prihlaseni a tokeny | `src/mcp/auth.ts` | | Prevod schemat | `src/mcp/schema.ts` | | Nastroje v katalogu | `src/data/mcpTools.ts` | -| Sluzba `mcp` | `src/data/services.ts` | +| Obe sluzby | `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` | @@ -183,26 +264,27 @@ text odpovedi. Neni to nedodelek u nas. ## 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 | -| Prihlaseni s presmerovanim | authorization code vyzaduje cloveka v prohlizeci, napojeni bezi bez nej | +| Chybi | Poznamka | +| -------------------------- | ----------------------------------------------------------------------- | +| Zdroje a prompty | server je umi vedle nastroju, viz nize | +| Nastroje pro model | dnes vybira nastroj clovek | +| stdio transport | umi se jen HTTP, tedy to, co delaji servery dostupne po siti | +| Prihlaseni s presmerovanim | authorization code vyzaduje cloveka v prohlizeci, napojeni bezi bez nej | + +### Zdroje a prompty + +MCP server vedle nastroju vystavuje **zdroje** (`resources/list`, +`resources/read`) a **prompty** (`prompts/list`, `prompts/get`). U EasyWebu to +neni okrajova vec: cislaky jako seznam entit, metadata entity nebo seznam +chybovych kodu jsou prave zdroje, ne nastroje. + +Do kroku automatizace se to hodi - "precti zdroj a pouzij hodnotu" je totez co +ciselnik. Neni to udelane, protoze zdroje maji URI sablonu misto schematu +argumentu, takze prevod na pole kroku je jina uloha nez u nastroju. Zdroje umi +navic vlastni strankovani po cislech stranek, ne kurzorem. ### 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. +Az bude krok "nechat model splnit ukol", muze dostat nastroje serveru jako sve +schopnosti a vybirat si sam. Cely klient uz na to je, chybi to napojeni na +model. diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index ad35ae7..5e28d06 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,48 @@ Nejnovejsi nahore. +## 2026-08-28 - dve MCP sluzby: obecna a EasyWeb, strankovani nastroju + +MCP je standard, ale **prihlaseni k nemu ne**. Oficialni specifikace stoji na +OAuth 2.1 a objevovani autorizacniho serveru pres `.well-known`. EasyWeb +(Centaur) ma prihlaseni vlastni: `POST /login` s HTTP Basic vrati trojici tokenu +a obnovuje se vlastnimi endpointy. Zadny OAuth, zadne `.well-known`, jina verze +protokolu, zadne SSE ani hlavicka sezeni. + +Proto jsou v katalogu **dve sluzby**, ne jedna s prepinacem: firma pri zakladani +konektoru vyplnuje neco jineho. U obecne ID a tajemstvi aplikace nebo hotovy +token, u EasyWebu jmeno, heslo a nazev zarizeni. Slucovat to by znamenalo +formular, kde je pulka poli vzdycky k nicemu, a hadani, ktera pulka to je. + +Obecna sluzba pritom **zustava plnohodnotna**. Vlastni server je duvod pridat +sluzbu, ne duvod zavrit dvere ostatnim. + +### Pribylo + +- `src/mcp/dialect.ts` - rozdily obou serveru na jednom miste: prihlaseni, + verze protokolu, jestli se prijima SSE a jestli se posila `Mcp-Session-Id`. + Rozesete po klientovi by u kazdeho dalsiho serveru pribyl dalsi `if` jinde. +- **Sluzba MCP EasyWeb.** Adresa, jmeno, heslo, nazev a otisk zarizeni. + Prihlasovaci adresy si portal odvodi z adresy serveru sam. Otisk se doplnuje + z ID konektoru, aby server poznal totez zarizeni. +- **Hotovy token u obecne sluzby.** Rada verejnych serveru nic jineho nenabizi + a bez toho by na ne neslo zalozit konektor. +- **Objevovani pres `WWW-Authenticate`.** Specifikace to ma jako povinnou cestu: + server u odpovedi 401 rekne, kde jsou jeho metadata. Pouziva se az kdyz obvykla + mista selzou, protoze to stoji volani navic. +- **Zivotnost z tela tokenu.** Kdyz server `expires_in` ani datum neposle, cte se + `exp` z JWT. To je presne pripad EasyWebu. +- **Strankovani nastroju.** Nastroj s parametrem `cursor` dostane v builderu + prepinac Nacist vsechny stranky. Kurzor je hodnota z odpovedi, takze v dobe + stavby stromu ho nikdo nezna a nejde ho vyplnit dopredu. Krok pak vraci navic + `items`, `pages`, `pageCount` a `truncated`. Strop je 20 stranek. + +### Opraveno + +Prihlaseni driv zkousela `password` grant a HTTP Basic proti hlavnimu endpointu. +Prvni OAuth 2.1 zrusil, druhe neni nikde ve specifikaci a u EasyWebu by stejne +neproslo - ten chce Basic na `/login`, ne na `/mcp`. + ## 2026-08-28 - MCP: prihlaseni jmenem a heslem, tokeny si resi portal Predchozi verze chtela po uzivateli token. To bylo spatne zadani: zakaznik diff --git a/src/data/mcpTools.ts b/src/data/mcpTools.ts index 28cecbb..56b1118 100644 --- a/src/data/mcpTools.ts +++ b/src/data/mcpTools.ts @@ -18,9 +18,10 @@ import type { Connector } from './connectorStore.js'; import { listConnectors } from './connectorStore.js'; -import { MCP_SERVICE_ID, setMcpOperations, type ServiceOperation } from './services.js'; +import { setMcpOperations, type ServiceOperation } from './services.js'; +import { MCP_SERVICE_IDS } from '../mcp/dialect.js'; import { listTenants } from './tenants.js'; -import { fieldsFromSchema, outputsFromTool } from '../mcp/schema.js'; +import { cursorFieldOf, fieldsFromSchema, outputsFromTool } from '../mcp/schema.js'; import type { McpTool } from '../mcp/client.js'; /** @@ -44,10 +45,29 @@ export function parseOperationId(id: string): { connectorId: string; toolName: s return { connectorId: rest.slice(0, separator), toolName: rest.slice(separator + 1) }; } +/** + * ID prepinace strankovani. + * + * Nezacina jako parametr nastroje, je nas - proto podtrzitko na zacatku. + * Server zadne pole s tim jmenem mit nemuze, protoze do argumentu se davaji + * jen vlastnosti z jeho schematu. + */ +export const ALL_PAGES_INPUT = '_allPages'; + +/** + * Strop na pocet stranek jednoho kroku. + * + * Rozbity server muze vracet porad tentyz kurzor. Bez stropu by krok bezel, + * dokud ho nezastavi timeout, a mezitim by volal cizi sluzbu donekonecna. + */ +export const MAX_TOOL_PAGES = 20; + interface Entry { connectorId: string; connectorName: string; tenantId: string; + /** Ktera ze sluzeb MCP to je. Nastroje se do katalogu radi pod ni. */ + serviceId: string; tools: McpTool[]; } @@ -63,12 +83,43 @@ const byConnector = new Map(); */ function toOperation(entry: Entry, tool: McpTool): ServiceOperation { const label = tool.title ?? tool.name; + const inputs = fieldsFromSchema(tool.inputSchema); + const outputs = outputsFromTool(tool); + + /* + * Nastroj, ktery umi strankovat, dostane prepinac navic. + * + * Bez nej by krok vratil prvni stranku a zbytek by uzivatel nemel jak + * dostat - kurzor je hodnota z odpovedi, kterou v dobe stavby stromu nikdo + * nezna, takze ho neslo vyplnit dopredu. + */ + if (cursorFieldOf(tool.inputSchema)) { + inputs.push({ + id: ALL_PAGES_INPUT, + label: 'Načíst všechny stránky', + kind: 'choice', + required: false, + options: [ + { value: '', label: 'Ne, jen první stránku' }, + { value: 'true', label: 'Ano, projít všechny' }, + ], + hint: + 'Nástroj vrací data po stránkách. Zapnuté je projde za sebou a výsledky spojí. ' + + `Nejvýš ${MAX_TOOL_PAGES} stránek, pak se krok zastaví a řekne to.`, + }); + outputs.push( + { id: 'items', name: 'Spojené položky', type: 'list', required: false }, + { id: 'pages', name: 'Jednotlivé stránky', type: 'list', required: false }, + { id: 'pageCount', name: 'Počet načtených stránek', type: 'number', required: true }, + ); + } + 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), + inputs, + outputFields: outputs, // Vykonna cast neni skript, ale vnitrni krok - vsechny nastroje obsluhuje // jeden. Priznak `implementation` je jen pro skripty, proto tu neni. }; @@ -76,10 +127,14 @@ function toOperation(entry: Entry, tool: McpTool): ServiceOperation { /** Prepocita, co se posila do katalogu. Vola se po kazde zmene mapy. */ function publish(): void { - const items: Array<{ tenantId: string; operation: ServiceOperation }> = []; + const items: Array<{ tenantId: string; serviceId: string; operation: ServiceOperation }> = []; for (const entry of byConnector.values()) { for (const tool of entry.tools) { - items.push({ tenantId: entry.tenantId, operation: toOperation(entry, tool) }); + items.push({ + tenantId: entry.tenantId, + serviceId: entry.serviceId, + operation: toOperation(entry, tool), + }); } } setMcpOperations(items); @@ -95,6 +150,7 @@ export function rememberMcpTools(connector: Connector): void { connectorId: connector.id, connectorName: connector.name, tenantId: connector.tenantId, + serviceId: connector.serviceId, tools, }); } @@ -127,8 +183,8 @@ 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 serviceId of tenantIds.length > 0 ? MCP_SERVICE_IDS : []) { + const connectors = await listConnectors(tenantIds, { serviceId }); for (const connector of connectors) { const tools = connector.mcp?.tools ?? []; if (tools.length === 0) continue; @@ -136,6 +192,7 @@ export async function refreshMcpTools(): Promise { connectorId: connector.id, connectorName: connector.name, tenantId: connector.tenantId, + serviceId: connector.serviceId, tools, }); } diff --git a/src/data/services.ts b/src/data/services.ts index 1cc116d..8de311b 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -20,6 +20,7 @@ import type { FieldType } from './conditions.js'; import type { User } from '../types.js'; +import { isMcpService, MCP_EASYWEB_SERVICE_ID, MCP_SERVICE_ID } from '../mcp/dialect.js'; export type ServiceCategory = /** Obecne veci, ktere ma kazdy. Nepotrebuji konektor. */ @@ -288,13 +289,6 @@ 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 { @@ -2408,24 +2402,26 @@ export const services: Service[] = [ }, /** - * MCP server firmy. + * MCP server podle oficialni specifikace. * - * 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`. + * Jedina sluzba v katalogu, ktera **nema zadne pevne operace** - rekne je az + * server. Doplnuje je `src/data/mcpTools.ts`. + * + * Prihlaseni ma tri podoby a firma vyplni tu, kterou ji provozovatel serveru + * dal. Vic jich je zamerne: verejne MCP servery vydavaji hotovy token, firemni + * jedou na OAuth. Kdyby slo jen jedno, cast serveru by nesla napojit. * * 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. + * naopak: sluzba zadnou adresu nema, kazda firma ma svuj server. Stejne to ma + * SMTP i EasyWeb nize. */ { id: MCP_SERVICE_ID, name: 'MCP server', category: 'ai', description: - 'Napojení na vlastní MCP server. Stačí adresa, jméno a heslo - portál si vyžádá seznam nástrojů a ty se pak dají použít jako kroky automatizace.', + 'Napojení na libovolný 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, @@ -2442,34 +2438,32 @@ export const services: Service[] = [ secret: false, hint: 'Celá adresa endpointu, například https://mcp.firma.cz/mcp. Musí být dostupná z internetu.', }, - /* - * Jmeno a heslo, ne token. - * - * Zakaznik dostane ke svemu serveru adresu, jmeno a heslo. Token nedostane - * a nema jak ho ziskat - vyda ho az autorizacni server a ma omezenou - * zivotnost. Obstarat ho, hlidat platnost a vcas ho obnovit je proto prace - * portalu (`src/mcp/auth.ts`), ne uzivatele. - * - * Proto ani jedno pole nemiri do hlavicky: hlavicka `Authorization` se - * pocita az pri volani z toho, co vydal autorizacni server. - */ { - id: 'username', - label: 'Jméno', + id: 'token', + label: 'Token', target: 'config', - name: 'username', - required: false, - secret: false, - hint: 'Jméno nebo ID aplikace, které jste dostali k serveru. Prázdné u serveru bez přihlášení.', - }, - { - id: 'password', - label: 'Heslo', - target: 'config', - name: 'password', + name: 'token', required: false, secret: true, - hint: 'Heslo nebo tajný klíč k tomu jménu. Portál si za ně sám vyzvedne přístup a obnovuje ho.', + hint: 'Když jste od provozovatele dostali hotový token. Portál ho pošle tak, jak je, a nic dalšího neřeší.', + }, + { + id: 'clientId', + label: 'ID aplikace', + target: 'config', + name: 'clientId', + required: false, + secret: false, + hint: 'Druhá možnost: server má přihlášení přes OAuth. Portál si pak přístup vyzvedne sám a obnovuje ho.', + }, + { + id: 'clientSecret', + label: 'Tajemství aplikace', + target: 'config', + name: 'clientSecret', + required: false, + secret: true, + hint: 'Patří k ID aplikace.', }, { id: 'tokenUrl', @@ -2495,6 +2489,81 @@ export const services: Service[] = [ actions: [], }, + /** + * MCP server EasyWebu (Centaur). + * + * Vlastni sluzba, ne varianta te predchozi. Duvod je v tom, co firma + * vyplnuje: **dostane adresu, jmeno a heslo**, zadne ID aplikace a zadny + * token. EasyWeb nema OAuth ani `.well-known`, prihlaseni je vlastni + * (`POST /login` s HTTP Basic vrati trojici tokenu). + * + * Slucovat to s obecnou sluzbou by znamenalo formular, kde je pulka poli + * vzdycky k nicemu, a hadani, ktera pulka to prave je. Rozdily jsou popsane + * v `src/mcp/dialect.ts`. + */ + { + id: MCP_EASYWEB_SERVICE_ID, + name: 'MCP EasyWeb', + category: 'ai', + description: + 'Napojení na MCP server EasyWebu. Stačí adresa, jméno a heslo - portál si vyžádá seznam nástrojů a ty se 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: 'Endpoint bez koncového lomítka, například https://web.firmy.cz/centaur/mcp. Přihlašovací adresy si portál odvodí sám.', + }, + { + id: 'username', + label: 'Jméno', + target: 'config', + name: 'username', + required: true, + secret: false, + hint: 'Uživatel, pod kterým se má portál k serveru hlásit.', + }, + { + id: 'password', + label: 'Heslo', + target: 'config', + name: 'password', + required: true, + secret: true, + hint: 'Portál si za jméno a heslo sám vyzvedne přístup a včas ho obnovuje. Token nikam nezadáváte.', + }, + { + id: 'deviceName', + label: 'Název zařízení', + target: 'config', + name: 'deviceName', + required: false, + secret: false, + hint: 'Pod tímhle názvem uvidíte přihlášení v logu serveru. Prázdné znamená WorkNuke.', + }, + { + id: 'fingerprint', + label: 'Otisk zařízení', + target: 'config', + name: 'fingerprint', + required: false, + secret: false, + hint: 'Server podle něj pozná, že jde pořád o totéž zařízení. Prázdné doplní portál podle konektoru.', + }, + ], + triggers: [], + actions: [], + }, + /** * Ukazka omezene viditelnosti: tuhle sluzbu vidi jen LogiTrans a spravce * platformy. Ostatni firmy ji v katalogu vubec nedostanou, takze se ani @@ -2765,11 +2834,11 @@ export function setScriptActions(byService: Map): vo * * Plni to `src/data/mcpTools.ts`. */ -let mcpOperations: Array<{ tenantId: string; operation: ServiceOperation }> = []; +let mcpOperations: Array<{ tenantId: string; serviceId: string; operation: ServiceOperation }> = []; /** Nahradi cely seznam nastroju. */ export function setMcpOperations( - items: Array<{ tenantId: string; operation: ServiceOperation }>, + items: Array<{ tenantId: string; serviceId: string; operation: ServiceOperation }>, ): void { mcpOperations = items; } @@ -2784,10 +2853,10 @@ const byName = (a: ServiceOperation, b: ServiceOperation): number => * 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[] { +export function mcpActionsFor(tenantId: string | null, serviceId: string): ServiceOperation[] { if (tenantId === null) return []; return mcpOperations - .filter((item) => item.tenantId === tenantId) + .filter((item) => item.tenantId === tenantId && item.serviceId === serviceId) .map((item) => item.operation) .sort(byName); } @@ -2798,8 +2867,11 @@ export function mcpActionsFor(tenantId: string | null): ServiceOperation[] { * 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); +function allMcpActions(serviceId: string): ServiceOperation[] { + return mcpOperations + .filter((item) => item.serviceId === serviceId) + .map((item) => item.operation) + .sort(byName); } /** @@ -2812,7 +2884,7 @@ export function actionsFor(serviceId: string): ServiceOperation[] { if (!service) return []; // MCP nema skripty, ma nastroje serveru. Napric firmami, viz `allMcpActions`. - if (serviceId === MCP_SERVICE_ID) return [...service.actions, ...allMcpActions()]; + if (isMcpService(serviceId)) return [...service.actions, ...allMcpActions(serviceId)]; const fromScripts = scriptActions.get(serviceId); if (!fromScripts || fromScripts.length === 0) return service.actions; @@ -2886,8 +2958,8 @@ export function withRuntimeOptions( */ 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)] }; + if (isMcpService(service.id)) { + return { ...service, actions: [...service.actions, ...mcpActionsFor(tenantId, service.id)] }; } return scriptActions.has(service.id) ? { ...service, actions: actionsFor(service.id) } : service; }); diff --git a/src/mcp/auth.ts b/src/mcp/auth.ts index ffca5f9..c633980 100644 --- a/src/mcp/auth.ts +++ b/src/mcp/auth.ts @@ -1,55 +1,53 @@ /** * Prihlaseni k MCP serveru. * - * Zakaznik dostane ke svemu serveru **adresu, jmeno a heslo**. Token nedostane - * a nema jak ho ziskat - vyda ho az autorizacni server a ma omezenou zivotnost. - * Obstarat ho, hlidat platnost a vcas ho obnovit je proto prace portalu, ne - * uzivatele. + * Dva zpusoby podle toho, o jaky server jde (viz `dialect.ts`): * - * Postup je vzdy stejny a v tomhle poradi: + * **Oficialni MCP.** Server je podle specifikace OAuth 2.1 resource server. + * Kde se prihlasit, rekne `/.well-known/oauth-protected-resource`, pripadne + * hlavicka `WWW-Authenticate` u odpovedi 401. Prihlasujeme se jako aplikace + * (`client_credentials`), protoze automatizace bezi bez cloveka u klavesnice + * a `authorization_code` potrebuje prohlizec. Kdo od serveru dostal hotovy + * token, vyplni rovnou ten - rada verejnych serveru nic jineho nenabizi. * - * 1. **Kde se prihlasit.** Bud je adresa vyplnena u konektoru, nebo se zjisti - * od serveru: `/.well-known/oauth-protected-resource` rekne, ktery - * autorizacni server za nim stoji, a jeho metadata rikaji token endpoint. - * 2. **Cim se prihlasit.** Nejdriv `client_credentials`, tedy jmeno a heslo - * jako identita aplikace. Kdyz to server odmitne, zkusi se `password`, tedy - * jmeno a heslo jako uzivatel. Ktere z toho firma dostala, se z udaju samych - * poznat neda a nutit ji to vybirat by znamenalo ptat se na neco, co nevi. - * 3. **Kdyz autorizacni server neni**, posle se HTTP Basic. Mensi servery - * zadny OAuth nemaji a jmeno s heslem je u nich presne tohle. + * **EasyWeb.** Zadny OAuth. `POST {server}/login` s HTTP Basic vrati trojici + * tokenu (pristupovy, obnovovaci a token zarizeni) a obnovuje se vlastnimi + * endpointy. Zakaznik dostane jmeno a heslo, token nikdy nevidi. * - * Token se drzi **jen v pameti**. Je kratkodoby, takze po restartu se o novy - * rekne znovu, a nikde neni zapsany. Do souboru ani do tabulky nepatri: - * ulozit kratkodoby token je vsechna rizika ulozeni bez jakekoliv vyhody. + * Spolecne pro obojí: **token je kratkodoby, jeho zivotnost urcuje server + * a hlidat ji je prace portalu**. Drzi se jen v pameti - po restartu se o novy + * rekne znovu. Ulozit kratkodoby token by znamenalo vsechna rizika ulozeni bez + * jakekoliv vyhody. */ import { config } from '../config.js'; import type { ResolvedTarget } from '../scripts/connections.js'; import { truncate } from '../scripts/util.js'; +import { dialectFor } from './dialect.js'; /** * O kolik driv nez vyprsi se token vymeni. * - * Bez rezervy by se stavalo, ze token projde kontrolou u nas a mezitim, nez - * dojde na server, vyprsi. Minuta je vic nez kterekoliv volani. + * Bez rezervy by se stavalo, ze token projde kontrolou u nas a nez dojde na + * server, vyprsi. Minuta je vic nez kterekoliv volani. */ const EXPIRY_MARGIN_MS = 60_000; /** - * Zivotnost, kdyz ji server neuvede. + * Zivotnost, kdyz ji server neuvede a neni ani v tokenu. * - * `expires_in` je v OAuth nepovinne. Drzet takovy token navzdy by znamenalo, ze - * po jeho expiraci prestane napojeni fungovat az do restartu. Petiminutovy - * odhad je vzdy bezpecny smerem dolu - nejhorsi dopad je volani navic. + * Drzet takovy token navzdy by znamenalo, ze po jeho expiraci prestane + * napojeni fungovat az do restartu. Petiminutovy odhad je bezpecny smerem + * dolu, nejhorsi dopad je prihlaseni navic. */ const DEFAULT_LIFETIME_MS = 300_000; /** Jak se portal prihlasil. Jde to do hlasky u konektoru. */ export type AuthMethod = | 'bez přihlášení' - | 'OAuth, jméno a heslo jako aplikace' - | 'OAuth, jméno a heslo jako uživatel' - | 'HTTP Basic'; + | 'vyplněný token' + | 'OAuth jako aplikace' + | 'jméno a heslo, EasyWeb'; export interface Authorization { headers: Record; @@ -58,18 +56,19 @@ export interface Authorization { method: AuthMethod; } -interface CachedToken { +interface Session { accessToken: string; /** Cas v ms, od ktereho uz se token nema pouzivat. */ expiresAt: number; refreshToken: string | null; - tokenUrl: string; method: AuthMethod; - /** Otisk udaju. Zmena hesla musi ulozeny token zneplatnit. */ + /** Kde se obnovuje. U OAuth token endpoint, u EasyWebu adresa serveru. */ + renewUrl: string; + /** Otisk udaju. Zmena hesla musi ulozene sezeni zneplatnit. */ fingerprint: string; } -const cache = new Map(); +const cache = new Map(); /** Chyba prihlaseni. Nese vetu pro uzivatele, ne stack. */ export class AuthFailure extends Error { @@ -83,22 +82,39 @@ export class AuthFailure extends Error { } interface Credentials { + serviceId: string; serverUrl: string; - username: string; - password: string; - /** Vyplnena adresa pro prihlaseni. Prazdne = zjistit od serveru. */ + /** Oficialni: hotovy token od provozovatele serveru. */ + token: string; + /** Oficialni: identita aplikace pro OAuth. */ + clientId: string; + clientSecret: string; tokenUrl: string; scope: string; + /** EasyWeb: prihlasovaci udaje uzivatele. */ + username: string; + password: string; + deviceName: string; + fingerprint: string; + /** Nahradni otisk zarizeni, kdyz ho firma nevyplnila. */ + device: string; } function credentialsOf(target: ResolvedTarget): Credentials { const value = (key: string): string => (target.serviceConfig[key] ?? '').trim(); return { + serviceId: target.serviceId, serverUrl: value('serverUrl'), - username: value('username'), - password: value('password'), + token: value('token'), + clientId: value('clientId'), + clientSecret: value('clientSecret'), tokenUrl: value('tokenUrl'), scope: value('scope'), + username: value('username'), + password: value('password'), + deviceName: value('deviceName'), + fingerprint: value('fingerprint'), + device: `worknuke-${target.connectorId ?? 'bez-konektoru'}`, }; } @@ -106,49 +122,227 @@ function credentialsOf(target: ResolvedTarget): Credentials { * Klic do kese. * * Konektor, ne adresa: dve firmy mohou mit tentyz server pod jinym uctem - * a token jedne nesmi obslouzit volani druhe. + * a sezeni jedne nesmi obslouzit volani druhe. */ function cacheKey(target: ResolvedTarget, credentials: Credentials): string { - return target.connectorId ?? `${credentials.serverUrl}|${credentials.username}`; + return target.connectorId ?? `${credentials.serverUrl}|${credentials.username}${credentials.clientId}`; } -/** Otisk udaju. Zmena hesla nebo adresy musi ulozeny token zahodit. */ +/** + * Otisk udaju. Zmena cehokoliv z nich musi ulozene sezeni zahodit. + * + * Tajne hodnoty se do otisku nedavaji cele, staci delka a posledni znak. + * Zmenu to zachyti a hodnotu z toho slozit nejde. + */ function fingerprintOf(credentials: Credentials): string { + const mask = (value: string): string => `${value.length}:${value.slice(-1)}`; return [ credentials.serverUrl, + credentials.clientId, credentials.username, - // Heslo se nikam neuklada, staci jeho delka a posledni znak - zmenu to - // zachyti a hodnotu z toho slozit nejde. - `${credentials.password.length}:${credentials.password.slice(-1)}`, credentials.tokenUrl, credentials.scope, + mask(credentials.token), + mask(credentials.clientSecret), + mask(credentials.password), ].join('|'); } -/** Hlavicky z hotoveho tokenu. */ function bearer(token: string, method: AuthMethod): Authorization { const header = `Bearer ${token}`; return { headers: { Authorization: header }, secrets: [token, header], method }; } -function basic(credentials: Credentials): Authorization { - const encoded = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); - const header = `Basic ${encoded}`; +// ------------------------------------------------------- cteni odpovedi + +/** Hodnota z JSONu bez ohledu na velikost pismen. Servery se v tom lisi. */ +function stringField(source: Record | null, ...names: string[]): string | null { + if (!source) return null; + const wanted = names.map((name) => name.toLowerCase()); + for (const [key, value] of Object.entries(source)) { + if (!wanted.includes(key.toLowerCase())) continue; + if (typeof value === 'string' && value.trim() !== '') return value.trim(); + } + return null; +} + +/** + * Kdy token vyprsi. + * + * Tri zdroje v poradi podle spolehlivosti: `expires_in` v sekundach, datum + * v odpovedi, a nakonec `exp` z tela samotneho tokenu. To posledni je pro + * servery, ktere zivotnost nikam nepisou, ale vydavaji JWT - a je to presne + * pripad EasyWebu. + */ +function expiryFrom(body: Record, token: string): number { + const seconds = body.expires_in ?? body.expiresIn ?? body.ExpiresIn; + if (typeof seconds === 'number' && seconds > 0) return Date.now() + seconds * 1000; + + const stamp = stringField(body, 'expiresAt', 'expiration', 'expires', 'expiresUtc', 'expirationUtc'); + if (stamp) { + const parsed = Date.parse(stamp); + if (!Number.isNaN(parsed)) return parsed; + } + + const claim = expiryFromJwt(token); + if (claim !== null) return claim; + + return Date.now() + DEFAULT_LIFETIME_MS; +} + +/** `exp` z prostredni casti JWT. null, kdyz to JWT neni. */ +function expiryFromJwt(token: string): number | null { + const parts = token.split('.'); + if (parts.length < 2) return null; + try { + const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const json = Buffer.from(padded, 'base64').toString('utf8'); + const payload = JSON.parse(json) as { exp?: unknown }; + return typeof payload.exp === 'number' ? payload.exp * 1000 : null; + } catch { + return null; + } +} + +/** Telo odpovedi jako objekt. Vyhazuje, kdyz to JSON neni. */ +async function readJson(response: Response, where: string): Promise> { + const raw = await response.text(); + if (raw.length > config.scriptMaxResponseBytes) { + throw new AuthFailure(`Odpověď z ${where} je nad povoleným limitem.`); + } + try { + const parsed: unknown = JSON.parse(raw); + if (parsed === null || typeof parsed !== 'object') throw new Error('neni objekt'); + return parsed as Record; + } catch { + throw new AuthFailure( + `Odpověď z ${where} není platný JSON. Míří adresa opravdu na přihlášení?`, + truncate(raw, config.errorDetailBytes), + ); + } +} + +// -------------------------------------------------------------- EasyWeb + +/** Adresa vedlejsiho endpointu EasyWebu. */ +function easyWebEndpoint(serverUrl: string, endpoint: string): string { + return `${serverUrl.replace(/\/+$/, '')}/${endpoint}`; +} + +/** + * Prihlaseni k EasyWebu. + * + * `POST {server}/login` s HTTP Basic a telem, ktere popisuje zarizeni. Server + * vrati pristupovy, obnovovaci a zarizeni token. Nazev a otisk zarizeni si + * server pamatuje, proto je otisk vazany na konektor - kazde napojeni je pro + * nej jine zarizeni. + */ +async function easyWebLogin(credentials: Credentials, signal: AbortSignal): Promise { + const url = easyWebEndpoint(credentials.serverUrl, 'login'); + const basic = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + signal, + headers: { + Authorization: `Basic ${basic}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + Name: credentials.deviceName || 'WorkNuke', + // Otisk vazany na konektor: server podle nej pozna, ze jde porad + // o totez zarizeni, a dve napojeni tehoz portalu si nesahaji do sezeni. + Fingerprint: credentials.fingerprint || credentials.device, + }), + }); + } catch (err) { + throw new AuthFailure( + `Nepodařilo se spojit s ${url}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (!response.ok) { + const detail = truncate(await response.text(), config.errorDetailBytes); + throw new AuthFailure( + response.status === 401 + ? `Server jméno a heslo nepřijal (HTTP 401 z ${url}).` + : `Přihlášení na ${url} vrátilo HTTP ${response.status}.`, + detail === '' ? null : detail, + ); + } + + const body = await readJson(response, url); + const accessToken = stringField(body, 'accessToken'); + if (!accessToken) { + throw new AuthFailure(`Odpověď z ${url} neobsahuje přístupový token.`); + } + return { - headers: { Authorization: header }, - secrets: [encoded, header, credentials.password], - method: 'HTTP Basic', + accessToken, + expiresAt: expiryFrom(body, accessToken), + refreshToken: stringField(body, 'refreshToken'), + method: 'jméno a heslo, EasyWeb', + renewUrl: credentials.serverUrl, + fingerprint: fingerprintOf(credentials), }; } -/** Kratke GET na metadata. Chyba neni vyjimka, je to "nenaslo se". */ -async function readMetadata(url: string, signal: AbortSignal): Promise | null> { +/** + * Obnova pristupoveho tokenu EasyWebu. + * + * `GET {server}/renew-access-token` s obnovovacim tokenem v hlavicce. Vraci + * null, kdyz to neprojde - pak se jde na plne prihlaseni, coz je stav po + * vyprseni obnovovaciho tokenu. + */ +async function easyWebRenew( + session: Session, + credentials: Credentials, + signal: AbortSignal, +): Promise { + if (!session.refreshToken) return null; + const url = easyWebEndpoint(session.renewUrl, 'renew-access-token'); + try { const response = await fetch(url, { method: 'GET', signal, - headers: { Accept: 'application/json' }, + headers: { Authorization: `Bearer ${session.refreshToken}`, Accept: 'application/json' }, }); + if (!response.ok) { + console.warn(`[mcp] obnova tokenu na ${url} vratila HTTP ${response.status}`); + return null; + } + + const body = await readJson(response, url); + const accessToken = stringField(body, 'accessToken'); + if (!accessToken) return null; + + return { + ...session, + accessToken, + expiresAt: expiryFrom(body, accessToken), + // Server obnovovaci token obvykle vymeni taky. Kdyz ne, plati stary. + refreshToken: stringField(body, 'refreshToken') ?? session.refreshToken, + fingerprint: fingerprintOf(credentials), + }; + } catch { + // Nepovedena obnova neni chyba, jde se na plne prihlaseni. + return null; + } +} + +// ---------------------------------------------------------------- OAuth + +/** Kratke GET na metadata. Chyba neni vyjimka, je to "nenaslo se". */ +async function readMetadata( + url: string, + signal: AbortSignal, +): Promise | null> { + try { + const response = await fetch(url, { method: 'GET', signal, headers: { Accept: 'application/json' } }); if (!response.ok) return null; const raw = await response.text(); if (raw.length > config.scriptMaxResponseBytes) return null; @@ -163,8 +357,8 @@ async function readMetadata(url: string, signal: AbortSignal): Promise | null, key: string): string | null { - const value = source?.[key]; - return typeof value === 'string' && value.trim() !== '' ? value.trim() : null; +/** + * Adresa metadat z hlavicky `WWW-Authenticate`. + * + * Specifikace to ma jako povinnou cestu: server u odpovedi 401 rekne, kde jsou + * jeho metadata. Pouziva se az kdyz obvykla mista selzou, protoze to stoji + * volani navic, ale bez toho by nesel napojit server, ktery si metadata dal + * jinam a oznamuje je jen timhle zpusobem. + */ +async function challengeMetadataUrl(serverUrl: string, signal: AbortSignal): Promise { + try { + const response = await fetch(serverUrl, { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'ping', params: {} }), + }); + if (response.status !== 401) return null; + + const challenge = response.headers.get('www-authenticate') ?? ''; + const match = /resource_metadata\s*=\s*"([^"]+)"/i.exec(challenge); + return match ? match[1] : null; + } catch { + return null; + } } /** * Kde se prihlasit. * - * Vraci null, kdyz autorizacni server neni k nalezeni. To neni chyba - server - * bez OAuth je bezny a pak se posle HTTP Basic. + * Postup podle specifikace: metadata chraneneho zdroje reknou autorizacni + * server, jeho metadata rikaji token endpoint. Vraci null, kdyz autorizacni + * server neni k nalezeni. */ async function discoverTokenUrl(serverUrl: string, signal: AbortSignal): Promise { let base: URL; @@ -192,20 +408,21 @@ async function discoverTokenUrl(serverUrl: string, signal: AbortSignal): Promise return null; } - // 1. Chraneny zdroj rekne, ktery autorizacni server za nim stoji. + const candidates = wellKnown(base, 'oauth-protected-resource'); + const fromChallenge = await challengeMetadataUrl(serverUrl, signal); + if (fromChallenge) candidates.unshift(fromChallenge); + let issuer: string | null = null; - for (const url of wellKnown(base, 'oauth-protected-resource')) { - const metadata = await readMetadata(url, signal); - const servers = metadata?.authorization_servers; + for (const url of candidates) { + const servers = (await readMetadata(url, signal))?.authorization_servers; if (Array.isArray(servers) && typeof servers[0] === 'string') { issuer = servers[0]; break; } } - // 2. Metadata autorizacniho serveru rikaji token endpoint. Kdyz se issuer - // nenasel, zkusi se metadata primo na serveru - mensi servery jsou - // autorizacnim serverem samy sobe. + // Kdyz se issuer nenasel, zkusi se metadata primo na serveru - mensi servery + // jsou autorizacnim serverem samy sobe. let issuerUrl: URL; try { issuerUrl = new URL(issuer ?? base.origin); @@ -222,23 +439,12 @@ async function discoverTokenUrl(serverUrl: string, signal: AbortSignal): Promise return null; } -interface TokenResponse { - accessToken: string; - expiresAt: number; - refreshToken: string | null; -} - -/** - * Jedno volani na token endpoint. - * - * Vraci null u odmitnuti, ktere ma smysl zkusit jinak (jiny typ prihlaseni). - * Vyhazuje jen tam, kde by dalsi pokus byl stejne marny. - */ -async function requestToken( +/** Jedno volani na token endpoint. */ +async function tokenRequest( tokenUrl: string, body: Record, signal: AbortSignal, -): Promise { +): Promise | null> { let response: Response; try { response = await fetch(tokenUrl, { @@ -251,125 +457,99 @@ async function requestToken( body: new URLSearchParams(body).toString(), }); } catch (err) { - const name = err instanceof Error ? err.name : ''; - if (name === 'AbortError' || name === 'TimeoutError') { - throw new AuthFailure(`Přihlášení na ${tokenUrl} nedoběhlo v limitu.`); - } throw new AuthFailure( `Nepodařilo se spojit s ${tokenUrl}: ${err instanceof Error ? err.message : String(err)}`, ); } - const raw = await response.text(); if (!response.ok) { - // Odmitnuti je odpoved, ne havarie: zkusi se dalsi zpusob prihlaseni. console.warn(`[mcp] prihlaseni na ${tokenUrl} vratilo HTTP ${response.status}`); return null; } - - let parsed: Record; - try { - parsed = JSON.parse(raw) as Record; - } catch { - throw new AuthFailure( - `Odpověď z ${tokenUrl} není platný JSON. Míří adresa opravdu na přihlášení?`, - truncate(raw, config.errorDetailBytes), - ); - } - - const accessToken = stringField(parsed, 'access_token'); - if (!accessToken) return null; - - /* - * Zivotnost urcuje server. Kdyz ji neuvede, plati kratky odhad - drzet token - * navzdy by znamenalo, ze po jeho expiraci prestane napojeni fungovat az do - * restartu. - */ - const seconds = typeof parsed.expires_in === 'number' ? parsed.expires_in : null; - const lifetime = seconds !== null && seconds > 0 ? seconds * 1000 : DEFAULT_LIFETIME_MS; - - return { - accessToken, - expiresAt: Date.now() + lifetime, - refreshToken: stringField(parsed, 'refresh_token'), - }; + return readJson(response, tokenUrl); } -/** Obnoveni pres refresh token. null = nepovedlo se, jde se prihlasit znovu. */ -async function refresh( +/** + * Prihlaseni aplikace pres OAuth. + * + * `client_credentials`, protoze automatizace bezi bez cloveka u klavesnice + * a `authorization_code` potrebuje prohlizec. `resource` podle RFC 8707 je + * povinny i tehdy, kdyz ho autorizacni server nezna - vaze token na server, + * pro ktery je urceny. + */ +async function oauthLogin( tokenUrl: string, - refreshToken: string, credentials: Credentials, signal: AbortSignal, -): Promise { - return requestToken( +): Promise { + const body = await tokenRequest( tokenUrl, { - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: credentials.username, - client_secret: credentials.password, + grant_type: 'client_credentials', + client_id: credentials.clientId, + client_secret: credentials.clientSecret, + resource: credentials.serverUrl, ...(credentials.scope ? { scope: credentials.scope } : {}), }, signal, ); + + const accessToken = body ? stringField(body, 'access_token', 'accessToken') : null; + if (!body || !accessToken) { + throw new AuthFailure( + `Server ${tokenUrl} přihlášení aplikace nepřijal. Ověřte ID a tajemství, ` + + 'případně vyplňte adresu pro přihlášení ručně.', + ); + } + + return { + accessToken, + expiresAt: expiryFrom(body, accessToken), + refreshToken: stringField(body, 'refresh_token', 'refreshToken'), + method: 'OAuth jako aplikace', + renewUrl: tokenUrl, + fingerprint: fingerprintOf(credentials), + }; } -/** - * Prihlaseni jmenem a heslem. - * - * Dva pokusy v poradi podle toho, co je pravdepodobnejsi u napojeni bez - * cloveka u klavesnice. Z udaju samych se poznat neda, ktery to je. - */ -async function login( - tokenUrl: string, +/** Obnova pres `refresh_token`. null = nepovedlo se, jde se prihlasit znovu. */ +async function oauthRenew( + session: Session, credentials: Credentials, signal: AbortSignal, -): Promise<{ token: TokenResponse; method: AuthMethod }> { - const scope: Record = credentials.scope ? { scope: credentials.scope } : {}; - // Indikator zdroje podle RFC 8707. Server, ktery ho nezna, ho ignoruje. - const resource: Record = { resource: credentials.serverUrl }; +): Promise { + if (!session.refreshToken) return null; - const asApplication = await requestToken( - tokenUrl, + const body = await tokenRequest( + session.renewUrl, { - grant_type: 'client_credentials', - client_id: credentials.username, - client_secret: credentials.password, - ...scope, - ...resource, + grant_type: 'refresh_token', + refresh_token: session.refreshToken, + client_id: credentials.clientId, + client_secret: credentials.clientSecret, + ...(credentials.scope ? { scope: credentials.scope } : {}), }, signal, ); - if (asApplication) { - return { token: asApplication, method: 'OAuth, jméno a heslo jako aplikace' }; - } - const asUser = await requestToken( - tokenUrl, - { - grant_type: 'password', - username: credentials.username, - password: credentials.password, - ...scope, - ...resource, - }, - signal, - ); - if (asUser) { - return { token: asUser, method: 'OAuth, jméno a heslo jako uživatel' }; - } + const accessToken = body ? stringField(body, 'access_token', 'accessToken') : null; + if (!body || !accessToken) return null; - throw new AuthFailure( - `Server ${tokenUrl} jméno a heslo nepřijal. Zkusili jsme přihlášení aplikace ` + - 'i uživatele. Ověřte údaje, případně vyplňte adresu pro přihlášení ručně.', - ); + return { + ...session, + accessToken, + expiresAt: expiryFrom(body, accessToken), + refreshToken: stringField(body, 'refresh_token', 'refreshToken') ?? session.refreshToken, + fingerprint: fingerprintOf(credentials), + }; } +// ----------------------------------------------------------------- ven + /** * Hlavicky pro volani MCP serveru. * - * Uvnitr se resi cely zivotni cyklus tokenu: platny se pouzije, prosly se + * Uvnitr se resi cely zivotni cyklus: platny token se pouzije, prosly se * obnovi nebo vymeni za novy. Volajici o tokenu nevi. */ export async function authorize( @@ -377,9 +557,20 @@ export async function authorize( signal: AbortSignal, ): Promise { const credentials = credentialsOf(target); + const dialect = dialectFor(credentials.serviceId); - // Server bez prihlaseni je bezny, hlavne u verejnych a vnitrofiremnich. - if (credentials.username === '' && credentials.password === '') { + /* + * Hotovy token od provozovatele serveru. Nic se nezjistuje ani neobnovuje - + * plati, dokud ho nekdo nezmeni. Rada verejnych serveru nic jineho nenabizi + * a bez teto vetve by na ne nesel zalozit konektor. + */ + if (dialect.auth === 'oauth' && credentials.token !== '') { + return bearer(credentials.token, 'vyplněný token'); + } + + const hasOauth = credentials.clientId !== '' || credentials.clientSecret !== ''; + const hasLogin = credentials.username !== '' || credentials.password !== ''; + if (dialect.auth === 'oauth' ? !hasOauth : !hasLogin) { return { headers: {}, secrets: [], method: 'bez přihlášení' }; } @@ -391,31 +582,38 @@ export async function authorize( if (Date.now() < cached.expiresAt - EXPIRY_MARGIN_MS) { return bearer(cached.accessToken, cached.method); } - // Vyprsel. Refresh token je levnejsi nez cele prihlaseni znovu. - if (cached.refreshToken) { - const renewed = await refresh(cached.tokenUrl, cached.refreshToken, credentials, signal); - if (renewed) { - cache.set(key, { ...cached, ...renewed, fingerprint }); - return bearer(renewed.accessToken, cached.method); - } + // Vyprsel. Obnova je levnejsi nez cele prihlaseni znovu. + const renewed = + dialect.auth === 'easyweb' + ? await easyWebRenew(cached, credentials, signal) + : await oauthRenew(cached, credentials, signal); + if (renewed) { + cache.set(key, renewed); + return bearer(renewed.accessToken, renewed.method); } } - const tokenUrl = credentials.tokenUrl || (await discoverTokenUrl(credentials.serverUrl, signal)); + let session: Session; + if (dialect.auth === 'easyweb') { + session = await easyWebLogin(credentials, signal); + } else { + const tokenUrl = + credentials.tokenUrl || (await discoverTokenUrl(credentials.serverUrl, signal)); + if (!tokenUrl) { + throw new AuthFailure( + `U serveru ${credentials.serverUrl} se nepodařilo najít, kde se přihlásit. ` + + 'Vyplňte adresu pro přihlášení ručně, nebo místo ID a tajemství zadejte hotový token.', + ); + } + session = await oauthLogin(tokenUrl, credentials, signal); + } - /* - * Bez autorizacniho serveru zbyva HTTP Basic. Neni to nouzove reseni: mensi - * MCP servery zadny OAuth nemaji a jmeno s heslem je u nich presne tohle. - */ - if (!tokenUrl) return basic(credentials); - - const { token, method } = await login(tokenUrl, credentials, signal); - cache.set(key, { ...token, tokenUrl, method, fingerprint }); - return bearer(token.accessToken, method); + cache.set(key, session); + return bearer(session.accessToken, session.method); } /** - * Zahodi ulozeny token. + * Zahodi ulozene sezeni. * * Vola se, kdyz server odmitne token, ktery jsme povazovali za platny - treba * proto, ze mu nekdo na druhe strane odebral opravneni driv, nez vyprsel. diff --git a/src/mcp/client.ts b/src/mcp/client.ts index e2859db..6111bde 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -26,15 +26,15 @@ import { targetSecrets, type ResolvedTarget } from '../scripts/connections.js'; import { isPrivateHost } from '../scripts/http.js'; import { createRedactor, truncate } from '../scripts/util.js'; import { AuthFailure, authorize, forgetToken, type AuthMethod } from './auth.js'; +import { dialectFor, type McpDialect } from './dialect.js'; /** - * Verze protokolu, kterou umime. + * Verzi protokolu urcuje druh serveru (`dialect.ts`). * - * Server smi odpovedet jinou - pak plati jeho a posila se dal v hlavicce + * Server smi v odpovedi rict 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' }; @@ -173,6 +173,8 @@ interface Session { redact: (value: string) => string; /** Jak se portal prihlasil. Jde to do hlasky u konektoru. */ authMethod: AuthMethod; + /** Cim se dany druh serveru lisi. */ + dialect: McpDialect; } let nextId = 1; @@ -202,10 +204,18 @@ async function rpc( signal, headers: { 'Content-Type': 'application/json', - // Obojí, protoze server si vybira, jestli odpovi telem nebo streamem. - Accept: 'application/json, text/event-stream', + /* + * Stream se nabizi jen tam, kde ho server umi. Rict serveru, ze + * prijmeme neco, co on neposila, nevadi, ale rict to serveru, ktery si + * hlavicku kontroluje, uz vadit muze. + */ + Accept: session.dialect.acceptEventStream + ? 'application/json, text/event-stream' + : 'application/json', 'MCP-Protocol-Version': session.protocolVersion, - ...(session.sessionId ? { 'Mcp-Session-Id': session.sessionId } : {}), + ...(session.dialect.useSessionHeader && session.sessionId + ? { 'Mcp-Session-Id': session.sessionId } + : {}), ...session.headers, }, body: JSON.stringify(body), @@ -220,8 +230,10 @@ async function rpc( } // 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; + if (session.dialect.useSessionHeader) { + const issued = response.headers.get('mcp-session-id'); + if (issued) session.sessionId = issued; + } const raw = await response.text(); if (raw.length > config.scriptMaxResponseBytes) { @@ -285,15 +297,17 @@ async function rpc( */ async function buildSession(target: ResolvedTarget, signal: AbortSignal): Promise { const url = serverUrl(target); + const dialect = dialectFor(target.serviceId); const auth = await authorize(target, signal); return { url, headers: { ...target.headers, ...auth.headers }, sessionId: null, - protocolVersion: PROTOCOL_VERSION, + protocolVersion: dialect.protocolVersion, redact: createRedactor([...targetSecrets(target), ...auth.secrets]), authMethod: auth.method, + dialect, }; } @@ -308,7 +322,7 @@ async function handshake(session: Session, signal: AbortSignal): Promise const result = (await rpc( session, 'initialize', - { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO }, + { protocolVersion: session.dialect.protocolVersion, capabilities: {}, clientInfo: CLIENT_INFO }, signal, )) as { protocolVersion?: string; serverInfo?: { name?: string; version?: string } }; diff --git a/src/mcp/dialect.ts b/src/mcp/dialect.ts new file mode 100644 index 0000000..07d2a1e --- /dev/null +++ b/src/mcp/dialect.ts @@ -0,0 +1,79 @@ +/** + * Dva druhy MCP serveru. + * + * MCP je standard, ale prihlaseni k nemu ne. Oficialni specifikace stoji na + * OAuth 2.1 vcetne objevovani autorizacniho serveru pres `.well-known`. + * EasyWeb (Centaur) ma **vlastni prihlaseni**: `POST /login` s HTTP Basic vrati + * trojici tokenu a ty se obnovuji vlastnimi endpointy. Zadny OAuth, zadne + * `.well-known`. + * + * Rozdily nejsou jen v prihlaseni, proto vlastni soubor. Kdyby se resily + * podminkami rozesetymi po klientovi, pribyl by u kazdeho dalsiho serveru + * dalsi `if` na jinem miste. + * + * Sluzby jsou dve, protoze **firma pri zakladani konektoru vyplnuje neco + * jineho**: u oficialniho ID a tajemstvi aplikace, u EasyWebu jmeno, heslo + * a nazev zarizeni. Hadat to z adresy nejde a nabidnout obojí najednou by + * znamenalo formular, kde je pulka poli vzdycky k nicemu. + */ + +/** Sluzba podle oficialni specifikace MCP. */ +export const MCP_SERVICE_ID = 'mcp'; + +/** Sluzba pro MCP server EasyWebu, tedy Centaur. */ +export const MCP_EASYWEB_SERVICE_ID = 'mcp-easyweb'; + +export const MCP_SERVICE_IDS = [MCP_SERVICE_ID, MCP_EASYWEB_SERVICE_ID] as const; + +export type McpAuthKind = 'oauth' | 'easyweb'; + +export interface McpDialect { + auth: McpAuthKind; + /** + * Verze protokolu v `initialize`. + * + * Plati verze, kterou vrati server. Tohle je jen navrh - u EasyWebu ale + * navic **musi sedet i v hlavicce** dalsich volani, protoze si ji server + * kontroluje proti sve konstante. + */ + protocolVersion: string; + /** + * Prijme klient odpoved jako SSE stream? + * + * EasyWeb ma SSE zatim jen jako zakomentovany kod, takze mu nema smysl + * rikat, ze stream umime. + */ + acceptEventStream: boolean; + /** + * Posila se zpatky `Mcp-Session-Id` z odpovedi? + * + * Oficialni transport na nem stoji. EasyWeb sezeni drzi u tokenu, hlavicku + * nevydava a poslat mu ji je zbytecne. + */ + useSessionHeader: boolean; +} + +const dialects: Record = { + [MCP_SERVICE_ID]: { + auth: 'oauth', + protocolVersion: '2025-06-18', + acceptEventStream: true, + useSessionHeader: true, + }, + [MCP_EASYWEB_SERVICE_ID]: { + auth: 'easyweb', + // Server si po handshaku kontroluje, ze hlavicka sedi na jeho konstantu. + protocolVersion: '2025-11-25', + acceptEventStream: false, + useSessionHeader: false, + }, +}; + +export function isMcpService(serviceId: string): boolean { + return serviceId in dialects; +} + +/** Neznama sluzba dostane oficialni chovani, tedy to podle standardu. */ +export function dialectFor(serviceId: string): McpDialect { + return dialects[serviceId] ?? dialects[MCP_SERVICE_ID]; +} diff --git a/src/mcp/schema.ts b/src/mcp/schema.ts index fb29581..c413e50 100644 --- a/src/mcp/schema.ts +++ b/src/mcp/schema.ts @@ -188,6 +188,57 @@ export function outputsFromTool(tool: McpTool): ProvidedField[] { return [...alwaysOutputs, ...own]; } +/** + * Jmeno parametru, kterym se nastroj strankuje. + * + * Nastroj muze vracet data po strankach: v odpovedi je kurzor na dalsi a ten + * se posle zpatky v argumentu. Vzorem je `db/search` v EasyWebu, ktery ma + * `limit` a `cursor` a vraci `NextCursor`. + * + * Pozna se to podle jmena, protoze schema JSON Schema nema jak rict "tohle je + * kurzor". Je to dohoda, ne standard - proto se hleda jen presne `cursor`, + * a ne cokoliv, co to slovo obsahuje. Nastroj s parametrem `cursorColor` by + * jinak zacal delat neco jineho, nez co ma. + */ +export function cursorFieldOf(schema: JsonSchema | undefined): string | null { + for (const [name, property] of propertiesOf(schema)) { + if (name.toLowerCase() !== 'cursor') continue; + const type = typeOf(property); + if (type === 'string' || type === 'unknown') return name; + } + return null; +} + +/** + * Kurzor na dalsi stranku z odpovedi nastroje. + * + * Jmeno se opet lisi server od serveru, proto vic variant. Prazdna hodnota + * a `null` znamenaji konec - server rika, ze dalsi stranka neni. + */ +export function nextCursorFrom(structured: Record | null): string | null { + if (!structured) return null; + for (const [key, value] of Object.entries(structured)) { + if (!['nextcursor', 'next_cursor', 'cursor'].includes(key.toLowerCase())) continue; + if (typeof value === 'string' && value.trim() !== '') return value; + } + return null; +} + +/** + * Prvni pole v odpovedi, tedy to, co se pri strankovani sklada dohromady. + * + * Nastroj vraci vedle kurzoru obvykle jednu kolekci - zaznamy, polozky, + * vysledky. Jak se jmenuje, urcuje server, takze se bere prvni, ktera je + * seznam. Kdyz zadna neni, strankovani slozi aspon texty. + */ +export function rowsFrom(structured: Record | null): unknown[] | null { + if (!structured) return null; + for (const value of Object.values(structured)) { + if (Array.isArray(value)) return value; + } + return null; +} + export interface ArgumentsResult { args: Record; /** Co se nepovedlo prevest. Prazdne = da se volat. */ diff --git a/src/openapi.ts b/src/openapi.ts index 756fa4b..e73558a 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -1790,8 +1790,9 @@ export function buildOpenApiDocument() { 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 ' + + 'Zepta se MCP serveru na tools/list a ulozi vysledek ke konektoru. Plati pro obe ' + + 'sluzby MCP (obecnou i EasyWeb) - jsou to jedine sluzby, u kterych 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 ' + diff --git a/src/routes/connectors.ts b/src/routes/connectors.ts index 38faa66..b064957 100644 --- a/src/routes/connectors.ts +++ b/src/routes/connectors.ts @@ -35,7 +35,6 @@ import { import { canSeeService, findService, - MCP_SERVICE_ID, serviceCatalog, serviceCategories, visibleServices, @@ -46,6 +45,7 @@ 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 { isMcpService } from '../mcp/dialect.js'; import { resolveTarget, serviceBaseUrl, targetSecrets } from '../scripts/connections.js'; import { createHttp } from '../scripts/http.js'; import { ScriptError } from '../scripts/types.js'; @@ -262,7 +262,7 @@ connectorsRouter.patch('/:id', async (req, res) => { } // 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); + if (isMcpService(updated.serviceId)) rememberMcpTools(updated); return res.json(toPublicConnector(updated)); }); diff --git a/src/runtime/builtinSteps.ts b/src/runtime/builtinSteps.ts index ed275c2..6c54853 100644 --- a/src/runtime/builtinSteps.ts +++ b/src/runtime/builtinSteps.ts @@ -10,10 +10,10 @@ */ 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 { ALL_PAGES_INPUT, findMcpTool, MAX_TOOL_PAGES, parseOperationId } from '../data/mcpTools.js'; +import { isMcpService } from '../mcp/dialect.js'; +import { callTool, type McpCallResult } from '../mcp/client.js'; +import { argumentsFrom, cursorFieldOf, nextCursorFrom, rowsFrom } from '../mcp/schema.js'; import { truncate } from '../scripts/util.js'; import { createIncident } from '../data/incidentStore.js'; import { sendMail } from '../mail/smtp.js'; @@ -781,28 +781,79 @@ async function runMcpTool( }; } - const outcome = await callTool(target, parsed.toolName, args); - if (!outcome.ok || !outcome.value) { - return { ok: false, summary: outcome.message, detail: outcome.detail, outputs: {} }; + /* + * Strankovani. + * + * Nastroj muze vracet data po strankach a kurzor na dalsi je hodnota + * z odpovedi - v dobe stavby stromu ji nikdo nezna, takze ji neslo vyplnit + * dopredu. Kdyz je prepinac zapnuty, projde se to za nas. + */ + const cursorField = cursorFieldOf(found.tool.inputSchema); + const allPages = cursorField !== null && (inputs[ALL_PAGES_INPUT] ?? '').trim() === 'true'; + + const texts: string[] = []; + const pages: Array | null> = []; + const items: unknown[] = []; + let last: McpCallResult | null = null; + let cursor: string | null = null; + let truncated = false; + + for (let page = 0; page < (allPages ? MAX_TOOL_PAGES : 1); page += 1) { + const pageArgs = cursor !== null && cursorField ? { ...args, [cursorField]: cursor } : args; + + const outcome = await callTool(target, parsed.toolName, pageArgs); + if (!outcome.ok || !outcome.value) { + return { ok: false, summary: outcome.message, detail: outcome.detail, outputs: {} }; + } + + last = outcome.value; + if (last.text !== '') texts.push(last.text); + pages.push(last.structured); + const rows = rowsFrom(last.structured); + if (rows) items.push(...rows); + + // Chyba nastroje zastavi strankovani. Volat dalsi stranku po tom, co server + // rekl, ze se neco nepovedlo, znamena jen vic volani a stejny vysledek. + if (!allPages || last.isError) break; + + const next = nextCursorFrom(last.structured); + // Stejny kurzor podruhe by znamenal nekonecnou smycku. + if (!next || next === cursor) break; + cursor = next; + + // Strop je dosazeny a server porad nabizi dalsi. Rekne se to nahlas, + // protoze tichy vysledek by vypadal jako uplny. + if (page === MAX_TOOL_PAGES - 1) truncated = true; } - const value = outcome.value; + if (!last) { + return { ok: false, summary: 'nástroj nevrátil žádnou odpověď', detail: null, outputs: {} }; + } + + const text = texts.join('\n'); + const summary = last.isError + ? `nástroj ${parsed.toolName} skončil chybou` + : allPages + ? `nástroj ${parsed.toolName} doběhl, stránek: ${pages.length}` + : `nástroj ${parsed.toolName} doběhl`; + 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), + ok: !last.isError, + summary: truncated ? `${summary}, strop stránek vyčerpán` : summary, + detail: text === '' ? null : truncate(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` + * podminka bez psani cesty. Spolecne 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, + ...(last.structured ?? {}), + text, + isError: last.isError, + structured: last.structured, + ...(allPages + ? { items, pages, pageCount: pages.length, truncated } + : {}), }, }; } @@ -811,6 +862,6 @@ async function runMcpTool( 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; + if (isMcpService(serviceId)) return runMcpTool; return handlers[`${serviceId}/${operationId}`]; }