diff --git a/documentation/00-pro-programatory.md b/documentation/00-pro-programatory.md index 683cd41..87a52a7 100644 --- a/documentation/00-pro-programatory.md +++ b/documentation/00-pro-programatory.md @@ -67,6 +67,10 @@ do stromu. Validace pri ukladani se pta katalogu, ne klienta. takze i cislo je text. Prevod na skutecny typ se dela az pred volanim, u toho, kdo vi, jaky typ to ma byt. +**Nektera pole konektoru vyplnuje portal, ne clovek.** Maji priznak `managed`, +ve formulari se nezobrazuji a zapisuji se zvlast, aby zapis neshodil vysledek +overeni. Dnes je to klic zarizeni u MCP EasyWebu. + **Prava se nedovozuji na klientovi.** Server vraci `GET /api/dashboard/access` s tim, co uzivatel smi. Dvoji vypocet se jednou rozejde. diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index a743ab4..d838668 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 | dve sluzby: obecna podle specifikace a MCP EasyWebu | +| MCP servery firmy | hotovo | obecna sluzba a MCP EasyWebu vcetne klice zarizeni | | 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/24-mcp-konektory.md b/documentation/24-mcp-konektory.md index c5b7d19..9456a90 100644 --- a/documentation/24-mcp-konektory.md +++ b/documentation/24-mcp-konektory.md @@ -37,12 +37,12 @@ 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 | +| Vlastnost | MCP server | MCP EasyWeb | +| ------------------------- | ------------ | ---------------------- | +| prihlaseni | OAuth 2.1 | klic zarizeni, podpisy | +| verze protokolu | `2025-06-18` | `2025-11-25` | +| odpoved jako SSE stream | ano | ano, i na bezne volani | +| hlavicka `Mcp-Session-Id` | ano | ne, sezeni je u tokenu | Verze protokolu neni kosmetika: EasyWeb si po handshaku kontroluje, ze hlavicka `MCP-Protocol-Version` sedi na jeho konstantu, a jinou odmitne. @@ -85,23 +85,10 @@ OAuth. Kdyby slo jen jedno, cast serveru by nesla napojit vubec. | ------------------ | ---------------------------------------------------- | | 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 | +| Heslo | pouzije se **jednou**, na registraci zarizeni | | 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. +Token se nezadava a zadavat nejde. Zakaznik dostane adresu, jmeno a heslo. ### Adresa se u EasyWebu srovnava @@ -110,51 +97,124 @@ Vlepena prihlasovaci cesta se odrizne a chybejici `/mcp` se doplni, takze cestu prisny - i `/mcp/login/` s lomitkem na konci vraci 404. **U obecne sluzby se adresa nemeni.** Cizi server muze mit endpoint kdekoliv -a "opravit" mu adresu podle naseho odhadu znamena rozbit napojeni, ktere by -jinak fungovalo. +a opravovat mu ji podle naseho odhadu znamena rozbit napojeni, ktere by jinak +fungovalo. -### Kdyz server odpovi 400 +## Prihlaseni k EasyWebu -`400 Bad Request` z `/login` **neni chyba pozadavku u nas**. Server ho ma jako -"malformed authorization request" a odpovida jim i na udaje, ktere neuzna. -Overeno proti bezicimu serveru: spatne heslo, prazdne heslo i neznamy ucet -vraceji tutez odpoved jako udaje spravne, telo je vzdycky jen `Bad Request` -v HTML. Rozlisit se to zvenku neda, takze to hlaska rika narovinu misto toho, -aby posilala cloveka hledat chybu v adrese. +Nejspecifictejsi cast celeho napojeni. Stoji za to ji precist celou, protoze +**chybny postup umi zablokovat ucet**. -Rozdil oproti chybejicimu prihlaseni je videt: `/login` **bez** hlavicky -`Authorization` vraci 401, s ni uz 400. +### Token patri zarizeni, ne uctu -## Prihlaseni a zivotnost tokenu +Server nevydava token proti jmenu a heslu, ale proti **zarizeni**. Zarizeni je +pár klicu ECDSA P-256. Jmeno a heslo se pouziji jedinkrat, kdyz se klic +registruje. Od te chvile je identitou klic a kazde volani, ktere s tokeny hybe, +se jim podepisuje. -Cely zivotni cyklus resi `src/mcp/auth.ts`. **Token je kratkodoby, jeho -zivotnost urcuje server a hlidat ji je prace portalu.** +| Co | Kde zije | Prezije restart | +| --------------------- | --------------------------------- | --------------- | +| klic zarizeni a otisk | mezi udaji konektoru, zasifrovane | ano | +| pristupovy token | pamet procesu | ne | +| obnovovaci token | pamet procesu | ne | +| token zarizeni | pamet procesu | ne | -| Situace | Co portal udela | -| -------------------------------- | ---------------------------------------------- | -| token plati | pouzije ho | -| do vyprseni zbyva min nez minuta | vymeni ho driv, nez vyprsi behem volani | -| 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 | +Klic **musi** prezit restart, jinak by se pri kazdem startu registrovalo nove +zarizeni. Tokeny naopak prezit **nesmi**: po restartu uz neplati a jejich +pouziti vypada jako pokus o zneuziti. -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. +Klic se uklada mezi udaje konektoru, protoze ty uz se ukladaji zasifrovane +a tohle je privatni klic. Pole ma priznak `managed`, takze ho ve formulari +nikdo nevidi ani nevyplnuje - portal si ho vyrobi sam pri prvnim prihlaseni. + +### Podpisy + +| Kde | Radky podpisu, spojene novym radkem | +| --------------- | ------------------------------------------------------------------------ | +| registrace | `CELEBRUM-MCP-KEY-REGISTRATION-V1`, jmeno, otisk, otisk klice, nonce | +| obnova pristupu | `CELEBRUM-MCP-POP-V1`, `renew-access`, hash tokenu, nonce, ID pozadavku | +| obnova zarizeni | `CELEBRUM-MCP-POP-V1`, `renew-refresh`, hash tokenu, nonce, ID pozadavku | + +Podpis musi byt v tvaru **P1363**, tedy holé `r || s`, 64 bajtu. Node podepisuje +ve vychozim nastaveni do DER a ten by protistrana neuznala. Vsechno kolem toho +je v `src/mcp/easyweb/crypto.ts`. + +### Retez s ustupy + +`ensureAccess` jde odshora dolu, kazdy dalsi clanek je drazsi: + +1. pristupovy token jeste plati (rezerva 90 sekund) - pouzije se +2. obnova obnovovacim tokenem +3. obnova tokenem zarizeni +4. cele prihlaseni jmenem a heslem + +Na 401 a 409 se pokracuje dalsim clankem, jina chyba probublá ven. **409 znamena, +ze token uz nekdo spotreboval** - tokeny jsou jednorazove a po kazde obnove ten +predchozi neplati. Obnovovaci token se pri kazde obnove pristupu meni, takze se +musi ulozit oba. + +Proto plati **jedno prihlaseni naraz na konektor**. Dve soubezne automatizace +nad tymz napojenim by jinak spustily dve obnovy, druha by pracovala se +spotrebovanym tokenem a server by zarizeni zablokoval. Resi to jedna sdilena +rozdelana operace: druhy volajici pocka na vysledek prvniho. + +### Kdyz server odpovi 400 nebo 401 + +| Kod | Co to znamena | +| --- | ----------------------------------------------------------- | +| 400 | telo neni v poradku, typicky chybi podpis nebo verejny klic | +| 401 | telo je v poradku, ale server neuznal jmeno a heslo | + +Overeno proti bezicimu serveru: **bez podpisu vraci 400 na cokoliv**, i na +spravne jmeno a heslo, a telo odpovedi je jen `Bad Request` v HTML. Se spravnym +telem a neplatnym uctem vraci 401. Rozdil mezi tim je to jedine, podle ceho jde +zvenku poznat, jestli je chyba v napojeni, nebo v uctu. + +## Zivotnost tokenu + +U obecne sluzby resi tokeny `src/mcp/auth.ts`, u EasyWebu +`src/mcp/easyweb/session.ts`. Spolecne plati, ze **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 rezerva | vymeni ho driv, nez vyprsi behem volani | +| server vydal obnovovaci token | obnovi jim, je to levnejsi nez prihlaseni | +| obnova neprojde | jde o clanek niz, nakonec cele prihlaseni | +| server odmitne token nebo sezeni | prihlasi se znovu a **jednou** to zopakuje | + +Kdy token vyprsi, se cte z `exp` v tele JWT. EasyWeb zivotnost jinam nepise +a pristupovy token plati zhruba pul hodiny. U obecne sluzby se driv zkusi +`expires_in` a datum v odpovedi. 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. +Server to rekne bud kodem 401, nebo chybou `-32008` **uvnitr uspesne odpovedi**. +To druhe by bez zvlastniho osetreni vypadalo jako chyba volani a krok by skoncil +misto toho, aby se prihlasil znovu. Zpusob prihlaseni se pise do hlasky u konektoru. Uzivatel vyplnil udaje a ma vedet, jak s nimi portal nalozil, nez zacne hledat chybu jinde. +## Handshake plati na token + +`initialize` a po nem `notifications/initialized` se posilaji **jednou na +token**, ne pred kazdym volanim. Server drzi sezeni u tokenu, takze po jeho +vymene se to musi zopakovat, jinak odpovi, ze relace neni inicializovana. + +## Odpoved muze byt stream + +Server si sam vybira, jestli odpovi JSON telem, nebo SSE streamem, a **streamem +odpovida i na obycejna volani**. Klient proto nabizi obojí a umi obojí precist. + +Cte se **po kouscich a konci se u prvni skutecne odpovedi**. Server stream +u dlouhych uloh sam nezavira a posila do nej tlukot srdce, takze cekani na +konec by skoncilo az timeoutem. Konce radku prichazi jako CRLF a bloky se +poznaji podle dvou novych radku, takze se to nejdriv srovna. + +Stream, ktery skonci bez odpovedi, **neni chyba**. Server to obcas udela +a pro krok je to prazdny vysledek. + ## Nacteni nastroju `POST /api/dashboard/connectors/{id}/mcp/tools` @@ -220,43 +280,80 @@ 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 +Strop je **20 stranek na krok**. Seznamy samotne (nastroje, zdroje, prompty) +se strankuji taky a skladaji se vzdycky - bez toho je videt jen prvni stranka, +tedy asi dvacet polozek, a vypada to jako uplny seznam. 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: +Vzdy ctyri hodnoty, at uz nastroj deklaruje cokoliv: | Vystup | Co je to | | ------------ | ----------------------------------------------------- | | `text` | textova cast odpovedi | +| `data` | rozbaleny obsah, at uz je to objekt, seznam nebo text | +| `structured` | strukturovana cast, kdyz ji odpoved ma | | `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`. +vyhravaji ty 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. +### Rozbaleni + +MCP vraci obsah zabaleny na vic zpusobu a nekdy vic vrstev pres sebe: + +``` +{ "content": [ { "type": "text", "text": "{\"records\": [...]}" } ] } +{ "structuredContent": { "records": [...] } } +{ "contents": [ ... ] } +``` + +Bez rozbaleni by v kroku skoncil JSON zapsany jako text a v podmince by se s nim +nedalo nic delat. Rozbaluje se proto rekurzivne, prednost ma `structuredContent` +- to je cast, kterou sam server oznacil za strukturovanou. + +## Nastroje, ktere bezi dlouho + +Nektere nastroje se nedaji zavolat rovnou. Server u nich obycejne volani odmitne +a ceka, ze se spusti jako **uloha**: krok si o ni rekne, dostane jeji ID a pak +ceka na vysledek. + +Cekame **dotazovanim** (`tasks/list`, pak `tasks/get`), protoze stav odtud je to +jedine, co je vzdycky pravda. Notifikace o prubehu chodi nejvys jednou a mohou +se minout. + +Dve veci, ktere se snadno prehlednou: + +- **Dokoncena uloha ze seznamu mizi.** "Neni v seznamu" tedy neznamena "bezi". + Po nekolika marnych kolech se prejde na primy dotaz, ktery zna i ulohy, ktere + uz ze seznamu vypadly. +- **Limit kroku se posouva.** Bezne volani ma na odpoved patnact sekund, uloha + deset minut. Kdyby platil ten kratky, kazda uloha by skoncila timeoutem. + +Kdyz uloha nedobehne ani do deseti minut, **neni to chyba**: na serveru bezi dal +a krok to rekne misto toho, aby predstiral selhani. ## Bezpecnost a limity - **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. +- **Heslo ani token neopousti server.** Heslo se z API nevraci vubec, tokeny + nikde nevznikaji jinde nez v pameti procesu. V logu jsou zredigovane oboje. +- **Klic zarizeni je ulozeny zasifrovane** mezi udaji konektoru, stejne jako + ostatni tajne hodnoty. - **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, stejne jako volani nastroje. +- Plati stejny strop na velikost odpovedi jako u skriptu + (`SCRIPT_MAX_RESPONSE_BYTES`), a to i u streamu, kde se pocita prubezne. +- Seznamy se strankuji nejvys stokrat, volani nastroje dvacetkrat. ## Co se **nedela** @@ -271,42 +368,48 @@ text odpovedi. Neni to nedodelek u nas. ## Kde to je -| 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` | -| 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` | -| Portal | `web/src/pages/dashboard/Connectors.tsx` | +| Cast | Soubor | +| -------------------- | ------------------------------------------- | +| Rozdily serveru | `src/mcp/dialect.ts` | +| Protokol | `src/mcp/client.ts` | +| Prihlaseni obecne | `src/mcp/auth.ts` | +| Klice EasyWebu | `src/mcp/easyweb/crypto.ts` | +| Zarizeni u konektoru | `src/mcp/easyweb/device.ts` | +| Tokeny EasyWebu | `src/mcp/easyweb/session.ts` | +| Prevod schemat | `src/mcp/schema.ts` | +| Nastroje v katalogu | `src/data/mcpTools.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` | +| Portal | `web/src/pages/dashboard/Connectors.tsx` | ## Co jeste chybi -| 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 | +| Chybi | Poznamka | +| ---------------------------- | ----------------------------------------------------------------------- | +| Zdroje a prompty | server je umi vedle nastroju, viz nize | +| Trvaly kanal notifikaci | GET SSE. Prubeh uloh se zatim zjistuje dotazovanim | +| Nahravani souboru po castech | `POST {server}/upload`, potreba u nastroju, ktere berou obrazky | +| Hlidani zmen na serveru | verze serveru a diff seznamu, tedy upozorneni, ze se kontrakty zmenily | +| 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. +neni okrajova vec: seznam entit, schema entity, chybove kody i samotne +objednavky se ctou jako zdroje, ne jako 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. +ciselnik. Neni to udelane, protoze zdroj ma URI sablonu misto schematu +argumentu, takze prevod na pole kroku je jina uloha nez u nastroju. Navic se +u nich query sklada rucne: datumy jsou ISO s dvojteckami a bezne kodovani by je +prepsalo tak, ze by filtr tise nefungoval. ### Nastroje pro model 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. +schopnosti a vybirat si sam. Cely klient uz na to je, chybi napojeni na model. diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 5e28d06..fa57053 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,71 @@ Nejnovejsi nahore. +## 2026-08-28 - MCP EasyWeb podle skutecne specifikace (auth v2) + +Predchozi verze posilala na `/login` jen jmeno, heslo a nazev zarizeni. Server +na to odpovidal `400 Bad Request` na cokoliv, i na spravne udaje, protoze +**cekal neco uplne jineho**. + +EasyWeb ma auth v2: token se nevydava proti uctu, ale proti **zarizeni**, a to +je pár klicu ECDSA P-256. Jmeno a heslo se pouziji jedinkrat, kdyz se klic +registruje, a soucasti registrace je podpis, kterym zarizeni dokazuje, ze +privatni klic k poslanemu verejnemu opravdu ma. Od te chvile se podepisuje +kazde volani, ktere s tokeny hybe. + +Overeno proti bezicimu serveru: se spravnym telem uz `/login` nevraci 400, ale +401 s neplatnymi udaji. Ucty z jejich testovaciho `settings.json` na verejnych +instancich neplati, takze dal se bez skutecnych udaju nedostanu. + +### Prihlaseni + +- `src/mcp/easyweb/crypto.ts` - klice, podpisy, otisky. Podpis musi byt + **P1363**, tedy holé `r || s`, 64 bajtu. Node podepisuje ve vychozim + nastaveni do DER a ten by protistrana neuznala. +- `src/mcp/easyweb/device.ts` - klic zarizeni se vyrobi jednou a **prezije + restart**: uklada se mezi udaje konektoru, ktere uz jsou zasifrovane. + Pole ma novy priznak `managed`, takze ho ve formulari nikdo nevidi. +- `src/mcp/easyweb/session.ts` - tri tokeny, retez s ustupy + (platny pristupovy, obnova obnovovacim, obnova zarizenim, cele prihlaseni), + **jedno prihlaseni naraz na konektor**, tokeny **jen v pameti**. + +Ta posledni tri pravidla nejsou opatrnost navic: tokeny jsou jednorazove, druhe +pouziti server odmita kodem 409 a **umi zarizeni zablokovat**. Dve soubezne +automatizace nad tymz napojenim by bez jedne sdilene rozdelane operace spustily +dve obnovy a druha by pracovala se spotrebovanym tokenem. + +### Transport + +- **Server si sam vybira, jestli odpovi JSON telem, nebo SSE streamem**, a + streamem odpovida i na obycejna volani. Klient proto nabizi obojí a cte + stream **po kouscich** - u dlouhych uloh ho server sam nezavira a posila do + nej tlukot srdce, takze cekani na konec by skoncilo az timeoutem. +- Handshake plati **na token**, ne na volani. Server drzi sezeni u tokenu. +- Odmitnute sezeni prijde jako chyba `-32008` **uvnitr uspesne odpovedi**. Bez + zvlastniho osetreni by to vypadalo jako chyba volani a krok by skoncil misto + toho, aby se prihlasil znovu. +- Seznamy se skladaji pres vsechny stranky. Bez toho je videt jen prvni, tedy + asi dvacet nastroju, a vypada to jako uplny seznam. +- Odpoved se rozbaluje rekurzivne (`structuredContent`, `contents`, `content`, + JSON zapsany jako text). Jinak by v kroku skoncil JSON jako retezec, se + kterym uz se v podmince nic nesvede. + +### Dlouho bezici nastroje + +Nastroj, ktery server odmitne spustit rovnou, se spusti jako **uloha** a ceka se +na ni dotazovanim. Stav z `tasks/list` a `tasks/get` je to jedine, co je vzdycky +pravda - notifikace o prubehu chodi nejvys jednou. Dokoncena uloha ze seznamu +mizi, takze po nekolika marnych kolech se prejde na primy dotaz. + +Limit kroku se pri tom posouva z patnacti sekund na deset minut. Kdyz uloha +nedobehne ani tak, neni to chyba: na serveru bezi dal a krok to rekne. + +### Co z toho zbylo nedodelane + +Trvaly kanal notifikaci (GET SSE), nahravani souboru po castech a hlidani zmen +kontraktu podle verze serveru. Vsechno je v +[24-mcp-konektory.md](24-mcp-konektory.md) v seznamu toho, co chybi. + ## 2026-08-28 - dve MCP sluzby: obecna a EasyWeb, strankovani nastroju MCP je standard, ale **prihlaseni k nemu ne**. Oficialni specifikace stoji na diff --git a/src/data/connectorStore.ts b/src/data/connectorStore.ts index 7207a05..c6bdab3 100644 --- a/src/data/connectorStore.ts +++ b/src/data/connectorStore.ts @@ -224,6 +224,22 @@ export function deleteConnector(id: string, tenantIds: string[]): Promise, + tenantIds: string[], +): Promise { + return repository.setManagedValues(id, values, tenantIds); +} + /** * Ulozi nastroje MCP serveru ke konektoru. * diff --git a/src/data/connectors/local.ts b/src/data/connectors/local.ts index 1a3d4db..d43cf6a 100644 --- a/src/data/connectors/local.ts +++ b/src/data/connectors/local.ts @@ -227,6 +227,19 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto return copy(row); }, + async setManagedValues(id, values, tenantIds) { + const row = rows.find((item) => item.id === id); + if (!row || !tenantIds.includes(row.tenantId)) return undefined; + + for (const [key, value] of Object.entries(values)) { + if (value === '') delete row.values[key]; + else row.values[key] = value; + } + row.updatedAt = nowIso(); + 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; diff --git a/src/data/connectors/postgres.ts b/src/data/connectors/postgres.ts index afdb5dd..b4d7a53 100644 --- a/src/data/connectors/postgres.ts +++ b/src/data/connectors/postgres.ts @@ -268,6 +268,33 @@ export const postgresConnectors: ConnectorRepository = { return row ? toConnector(row) : undefined; }, + async setManagedValues(id, values, tenantIds) { + if (tenantIds.length === 0) return undefined; + + return transaction(async (client) => { + const current = await client.query( + `SELECT ${COLUMNS} FROM connectors WHERE id = $1 AND tenant_id = ANY($2) FOR UPDATE`, + [id, tenantIds], + ); + const row = current.rows[0]; + if (!row) return undefined; + + const merged = openAll(row.secrets); + for (const [key, value] of Object.entries(values)) { + if (value === '') delete merged[key]; + else merged[key] = value; + } + + const updated = await client.query( + `UPDATE connectors SET secrets = $2::jsonb, updated_at = now() + WHERE id = $1 + RETURNING ${COLUMNS}`, + [id, JSON.stringify(sealAll(merged))], + ); + return toConnector(updated.rows[0]); + }); + }, + async setTools(id, tools: McpToolset | null, tenantIds) { if (tenantIds.length === 0) return undefined; const row = await queryOne( diff --git a/src/data/connectors/types.ts b/src/data/connectors/types.ts index 6ecd2dc..647987f 100644 --- a/src/data/connectors/types.ts +++ b/src/data/connectors/types.ts @@ -183,6 +183,18 @@ export interface ConnectorRepository { check: ConnectorCheck | null, tenantIds: string[], ): Promise; + /** + * Zapise hodnoty, ktere spravuje portal (`ServiceCredentialField.managed`). + * + * Vlastni metoda, ne `update`: zmena udaju od uzivatele znehodnocuje overeni + * konektoru, ale zapis klice zarizeni ne - ten se deje **pri** uspesnem + * prihlaseni a shodil by stav na neovereny hned po tom, co se overil. + */ + setManagedValues( + id: string, + values: Record, + tenantIds: string[], + ): Promise; /** Ulozi nastroje MCP serveru. Vlastni metoda, aby se nemichaly s udaji. */ setTools( id: string, diff --git a/src/data/services.ts b/src/data/services.ts index 8de311b..938db51 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -93,6 +93,15 @@ export interface ServiceCredentialField { * Redakce v logu se dela na obojí, na hodnotu i na celou hlavicku. */ prefix?: string; + /** + * true = hodnotu **spravuje portal**, ne uzivatel. + * + * Ve formulari se nezobrazuje a nikdo ji nevyplnuje. Zapisuje ji az napojeni + * samo, kdyz si za behu neco vyrobi - klic zarizeni u MCP EasyWebu. Bez + * tohohle priznaku by uzivatel videl pole, do ktereho nema co psat, a mohl + * by ho prepsat. + */ + managed?: boolean; } /** @@ -2539,7 +2548,7 @@ export const services: Service[] = [ 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.', + hint: 'Použije se jednou, na registraci zařízení. Dál se portál hlásí klíčem, který si vyrobí sám.', }, { id: 'deviceName', @@ -2550,14 +2559,31 @@ export const services: Service[] = [ secret: false, hint: 'Pod tímhle názvem uvidíte přihlášení v logu serveru. Prázdné znamená WorkNuke.', }, + /* + * Klic zarizeni. Vyrabi ho portal pri prvnim prihlaseni a od te chvile je + * to identita, kterou server pozna - jmeno a heslo uz se nepouziva. + * + * Je to pole konektoru, a ne zvlastni tabulka, protoze udaje konektoru se + * uz ukladaji zasifrovane a tohle je privatni klic. `managed` znamena, ze + * ho ve formulari nikdo nevidi a nevyplnuje. + */ { - id: 'fingerprint', + id: 'deviceJwk', + label: 'Klíč zařízení', + target: 'config', + name: 'deviceJwk', + required: false, + secret: true, + managed: true, + }, + { + id: 'deviceFingerprint', label: 'Otisk zařízení', target: 'config', - name: 'fingerprint', + name: 'deviceFingerprint', 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.', + managed: true, }, ], triggers: [], diff --git a/src/mcp/auth.ts b/src/mcp/auth.ts index 6d9e200..0ea524f 100644 --- a/src/mcp/auth.ts +++ b/src/mcp/auth.ts @@ -10,9 +10,10 @@ * a `authorization_code` potrebuje prohlizec. Kdo od serveru dostal hotovy * token, vyplni rovnou ten - rada verejnych serveru nic jineho nenabizi. * - * **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. + * **EasyWeb.** Zadny OAuth. Server vydava tokeny **zarizeni**, ne uctu: jmeno + * a heslo se pouziji jednou na registraci paru klicu a od te chvile se kazda + * obnova podepisuje. Cely postup je v `easyweb/session.ts`, tady je jen odbocka + * k nemu. * * 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 @@ -24,6 +25,7 @@ import { config } from '../config.js'; import type { ResolvedTarget } from '../scripts/connections.js'; import { truncate } from '../scripts/util.js'; import { dialectFor, normalizeServerUrl } from './dialect.js'; +import { ensureAccess, forgetSession, type EasyWebCredentials } from './easyweb/session.js'; /** * O kolik driv nez vyprsi se token vymeni. @@ -42,12 +44,15 @@ const EXPIRY_MARGIN_MS = 60_000; */ const DEFAULT_LIFETIME_MS = 300_000; +/** Cim se portal predstavuje serveru. Jde to do jeho logu. */ +const CLIENT_INFO = 'worknuke/1.0'; + /** Jak se portal prihlasil. Jde to do hlasky u konektoru. */ export type AuthMethod = | 'bez přihlášení' | 'vyplněný token' | 'OAuth jako aplikace' - | 'jméno a heslo, EasyWeb'; + | 'klíč zařízení, EasyWeb'; export interface Authorization { headers: Record; @@ -95,9 +100,6 @@ interface Credentials { username: string; password: string; deviceName: string; - fingerprint: string; - /** Nahradni otisk zarizeni, kdyz ho firma nevyplnila. */ - device: string; } function credentialsOf(target: ResolvedTarget): Credentials { @@ -115,8 +117,6 @@ function credentialsOf(target: ResolvedTarget): Credentials { username: value('username'), password: value('password'), deviceName: value('deviceName'), - fingerprint: value('fingerprint'), - device: `worknuke-${target.connectorId ?? 'bez-konektoru'}`, }; } @@ -224,130 +224,6 @@ async function readJson(response: Response, where: string): 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); - /* - * 400 tady neni rozbity pozadavek u nas. - * - * Server ma `400 Bad Request` jako "malformed authorization request" - * a odpovida jim i na udaje, ktere neuzna - overeno proti bezicimu - * serveru, kde spatne heslo, prazdne heslo i neznamy ucet vraceji stejnou - * odpoved jako spravne udaje. Rozlisit se to zvenku neda, takze hlaska - * musi rict obojí, jinak clovek hleda chybu v adrese. - */ - throw new AuthFailure( - response.status === 401 || response.status === 400 - ? `Server přihlášení nepřijal (HTTP ${response.status} z ${url}). ` + - 'Ověřte jméno a heslo a to, že účet na tomhle serveru existuje. ' + - 'Tenhle server odpovídá stejným kódem i na neznámý účet, takže víc ' + - 'z odpovědi poznat nejde.' - : `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 { - accessToken, - expiresAt: expiryFrom(body, accessToken), - refreshToken: stringField(body, 'refreshToken'), - method: 'jméno a heslo, EasyWeb', - renewUrl: credentials.serverUrl, - fingerprint: fingerprintOf(credentials), - }; -} - -/** - * 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: { 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". */ @@ -569,27 +445,48 @@ async function oauthRenew( export async function authorize( target: ResolvedTarget, signal: AbortSignal, + force = false, ): Promise { const credentials = credentialsOf(target); const dialect = dialectFor(credentials.serviceId); + if (dialect.auth === 'easyweb') { + if (!target.connectorId || !target.tenantId) { + throw new AuthFailure('Napojení na MCP EasyWebu jde použít jen přes konektor firmy.'); + } + if (credentials.username === '' || credentials.password === '') { + throw new AuthFailure('Vyplňte jméno a heslo, bez nich server zařízení nezaregistruje.'); + } + + const easyWeb: EasyWebCredentials = { + connectorId: target.connectorId, + tenantId: target.tenantId, + serverUrl: credentials.serverUrl, + username: credentials.username, + password: credentials.password, + deviceName: credentials.deviceName || 'WorkNuke', + clientInfo: CLIENT_INFO, + }; + const token = await ensureAccess(easyWeb, signal, force); + return bearer(token, 'klíč zařízení, EasyWeb'); + } + /* * 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 !== '') { + if (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) { + if (credentials.clientId === '' && credentials.clientSecret === '') { return { headers: {}, secrets: [], method: 'bez přihlášení' }; } const key = cacheKey(target, credentials); const fingerprint = fingerprintOf(credentials); + if (force) cache.delete(key); const cached = cache.get(key); if (cached && cached.fingerprint === fingerprint) { @@ -597,42 +494,35 @@ export async function authorize( return bearer(cached.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); + const renewed = await oauthRenew(cached, credentials, signal); if (renewed) { cache.set(key, renewed); return bearer(renewed.accessToken, renewed.method); } } - 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); + 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.', + ); } + const session = await oauthLogin(tokenUrl, credentials, signal); cache.set(key, session); return bearer(session.accessToken, session.method); } /** - * Zahodi ulozene sezeni. + * Zahodi ulozene prihlaseni konektoru. * - * Vola se, kdyz server odmitne token, ktery jsme povazovali za platny - treba - * proto, ze mu nekdo na druhe strane odebral opravneni driv, nez vyprsel. - * Dalsi pokus si rekne o novy. + * Vola se, kdyz uz napojeni nema co drzet - typicky pri smazani konektoru. + * Behem volani se misto toho pouziva `force`, ktery u EasyWebu zkusi levnejsi + * obnovu driv nez cele prihlaseni. */ export function forgetToken(connectorId: string | null): void { - if (connectorId) cache.delete(connectorId); + if (!connectorId) return; + cache.delete(connectorId); + forgetSession(connectorId); } diff --git a/src/mcp/client.ts b/src/mcp/client.ts index 9e87f10..9a327ed 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -3,15 +3,15 @@ * * 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. + * (`tools/call`). * * 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`). + * - server si **sam vybira**, jestli odpovi JSON telem, nebo SSE streamem, + * a stream nemusi sam zavrit - musi se cist po kouscich a skoncit hned, jak + * prijde odpoved, + * - pred prvnim volanim je povinny handshake a plati **na token**, + * - odmitnute sezeni se pozna i podle chyby uvnitr odpovedi, ne jen podle + * HTTP kodu. * * 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 - @@ -25,27 +25,31 @@ 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'; -import { AuthFailure, authorize, forgetToken, type AuthMethod } from './auth.js'; +import { AuthFailure, authorize, type AuthMethod } from './auth.js'; import { dialectFor, normalizeServerUrl, type McpDialect } from './dialect.js'; -/** - * Verzi protokolu urcuje druh serveru (`dialect.ts`). - * - * 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. - */ - /** Kdo se predstavi serveru. Nektere servery si to pisou do logu. */ -const CLIENT_INFO = { name: 'worknuke', version: '1' }; +const CLIENT_INFO = { name: 'worknuke', version: '1.0' }; /** - * Strop na strankovani `tools/list`. + * Strop na strankovani seznamu. * - * Server vraci nastroje po strankach a rika kurzor na dalsi. Rozbity server + * Server vraci polozky 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; +const MAX_PAGES = 100; + +/** + * Kod, kterym server rika, ze sezeni uz neplati. + * + * Prijde **uvnitr uspesne odpovedi**, ne jako HTTP kod, takze bez teto + * konstanty by to vypadalo jako chyba volani a krok by skoncil misto toho, aby + * se prihlasil znovu. + */ +const SESSION_LOST_CODE = -32008; + +/** Kod, kterym server rika, ze nastroj se musi spustit jako uloha. */ +const TASK_REQUIRED_CODE = -32601; /** Schema podle JSON Schema. Tvar se prochazi az v `schema.ts`. */ export type JsonSchema = Record; @@ -79,10 +83,14 @@ export interface McpToolset { export interface McpCallResult { /** Textova cast odpovedi, spojena pres vsechny bloky. */ text: string; - /** Strukturovana cast. Ma ji jen nastroj, ktery deklaruje `outputSchema`. */ + /** Strukturovana cast, uz rozbalena. Viz `deep`. */ structured: Record | null; + /** Rozbaleny obsah, at uz je to objekt, seznam nebo text. */ + data: unknown; /** true = nastroj rekl, ze se nepovedlo. Neni to chyba spojeni. */ isError: boolean; + /** Vyplnene, kdyz se nastroj spustil jako uloha na pozadi. */ + taskId: string | null; } /** @@ -106,6 +114,10 @@ class McpFailure extends Error { message: string, readonly status: number | null = null, readonly detail: string | null = null, + /** true = server rika, ze sezeni neplati. Ma smysl se prihlasit znovu. */ + readonly sessionLost = false, + /** Kod chyby JSON-RPC, kdyz ji server poslal. */ + readonly code: number | null = null, ) { super(message); this.name = 'McpFailure'; @@ -141,32 +153,11 @@ function serverUrl(target: ResolvedTarget): URL { 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; + /** Hodnota hlavicky `Authorization`. Pozna se podle ni zmena tokenu. */ + authHeader: string; /** ID sezeni z hlavicky odpovedi. Server ho mit nemusi. */ sessionId: string | null; protocolVersion: string; @@ -175,15 +166,106 @@ interface Session { authMethod: AuthMethod; /** Cim se dany druh serveru lisi. */ dialect: McpDialect; + connectorId: string | null; + /** Posune limit celeho volani. Pouziva se, kdyz se ceka na ulohu. */ + extend: (ms: number) => void; } +/** + * Pro ktery token uz probehl handshake. + * + * Klic je konektor, hodnota je hlavicka `Authorization`. Jina hlavicka znamena + * jiny token, tedy jine sezeni, a handshake se musi zopakovat. Servery, ktere + * si sezeni drzi u tokenu, jinak odpovi, ze relace neni inicializovana. + */ +const initialized = new Map(); + let nextId = 1; +// ------------------------------------------------------------------- SSE + +/** Jedna zprava z bloku SSE. `null` = blok neni odpoved, ma se preskocit. */ +function messageFromBlock(block: string): Record | null { + const data = block + .split('\n') + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()) + .join(''); + if (data.trim() === '') return null; + + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + // Nekompletni blok neni duvod skoncit, dalsi muze byt v poradku. + return null; + } + if (parsed === null || typeof parsed !== 'object') return null; + + const message = parsed as Record; + // Notifikace o prubehu nas nezajimaji, ceka se na vysledek. + if (typeof message.method === 'string' && message.method.startsWith('notifications/')) return null; + return 'result' in message || 'error' in message ? message : null; +} + +/** + * Precte SSE odpoved a skonci u prvni skutecne odpovedi. + * + * Cte se **po kouscich**, ne cele telo najednou. Server stream u dlouhych uloh + * sam nezavira a posila do nej tlukot srdce, takze cekani na konec by skoncilo + * az timeoutem. + * + * `null` znamena, ze stream skoncil, aniz by odpoved prisla. Server to obcas + * udela a pro volajiciho to neni chyba spojeni, ale prazdny vysledek. + */ +async function readEventStream(response: Response): Promise | null> { + const body = response.body; + if (!body) return null; + + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let size = 0; + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + + size += value.byteLength; + if (size > config.scriptMaxResponseBytes) { + throw new McpFailure( + `Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`, + response.status, + ); + } + + // .NET posila konce radku jako CRLF, blok se ale pozna podle dvou LF. + buffer = (buffer + decoder.decode(value, { stream: true })).replace(/\r\n/g, '\n'); + + for (;;) { + const end = buffer.indexOf('\n\n'); + if (end === -1) break; + const block = buffer.slice(0, end); + buffer = buffer.slice(end + 2); + + const message = messageFromBlock(block); + if (message) return message; + } + } + return null; + } finally { + // Zavrit hned. Bez toho by spojeni drzelo az do timeoutu. + await reader.cancel().catch(() => undefined); + } +} + +// ------------------------------------------------------------- JSON-RPC + /** * Jedno volani JSON-RPC. * - * `expectResult: false` je pro notifikace - na ty server neodpovida telem, - * jen kodem 202. + * `expectResult: false` je pro notifikace - na ty server neodpovida telem. */ async function rpc( session: Session, @@ -203,11 +285,11 @@ async function rpc( method: 'POST', signal, headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'application/json; charset=utf-8', /* - * 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. + * Obojí. Server si sam vybira, cim odpovi, a na MCP korenu odpovida + * streamem i na obycejne volani. Nabidnout jen JSON znamena, ze cast + * serveru odmitne cely pozadavek. */ Accept: session.dialect.acceptEventStream ? 'application/json, text/event-stream' @@ -235,96 +317,111 @@ async function rpc( 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)); + const isStream = response.headers.get('content-type')?.includes('event-stream') ?? false; if (!response.ok) { + // Telo chyby byva male, u nej se po kouscich cist nemusi. + const raw = await response.text().catch(() => ''); + const detail = session.redact(truncate(raw, config.errorDetailBytes)); 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 === 401 ? ' Server přístup odmítl.' : ''), response.status, detail === '' ? null : detail, + response.status === 401, ); } - if (!expectResult) return undefined; - if (raw.trim() === '') { - throw new McpFailure(`${method} vrátilo prázdnou odpověď.`, response.status); + if (!expectResult) { + if (isStream) await readEventStream(response).catch(() => null); + else await response.text().catch(() => ''); + return undefined; } - const isStream = response.headers.get('content-type')?.includes('event-stream') ?? false; - let parsed: unknown; + let envelope: Record | null; if (isStream) { - parsed = parseEventStream(raw); + envelope = await readEventStream(response); + // Stream skoncil bez odpovedi. Server to obcas udela, neni to chyba. + if (!envelope) return null; } else { + 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, + ); + } + if (raw.trim() === '') return null; try { - parsed = JSON.parse(raw); + envelope = JSON.parse(raw) as Record; } catch { throw new McpFailure( `${method} nevrátilo platný JSON. Míří adresa opravdu na MCP server?`, response.status, - detail, + session.redact(truncate(raw, config.errorDetailBytes)), ); } } - const envelope = parsed as { result?: unknown; error?: { code?: number; message?: string } }; - if (envelope.error) { - // Chyba protokolu, ne chyba prenosu. Server napsal, co mu vadilo. + const error = envelope.error as { code?: number; message?: string } | undefined; + if (error) { + const code = typeof error.code === 'number' ? error.code : null; throw new McpFailure( - `Server odmítl ${method}: ${envelope.error.message ?? 'bez zprávy'}`, + `Server odmítl ${method}: ${error.message ?? 'bez zprávy'}`, response.status, - detail, + session.redact(truncate(JSON.stringify(error), config.errorDetailBytes)), + code === SESSION_LOST_CODE, + code, ); } - return envelope.result; + return envelope.result ?? null; } -/** - * Sezeni pred handshakem: adresa, prihlaseni a redakce. - * - * Token si portal obstarava sam z jmena a hesla u konektoru, viz `auth.ts`. - * Vysledek prihlaseni se rovnou pridava do redakce, aby se token nedostal do - * logu ani do hlasky o chybe. - */ -async function buildSession(target: ResolvedTarget, signal: AbortSignal): Promise { +// --------------------------------------------------------------- sezeni + +async function buildSession( + target: ResolvedTarget, + signal: AbortSignal, + force: boolean, + extend: (ms: number) => void, +): Promise { const url = serverUrl(target); const dialect = dialectFor(target.serviceId); - const auth = await authorize(target, signal); + const auth = await authorize(target, signal, force); return { url, headers: { ...target.headers, ...auth.headers }, + authHeader: auth.headers.Authorization ?? '', sessionId: null, protocolVersion: dialect.protocolVersion, redact: createRedactor([...targetSecrets(target), ...auth.secrets]), authMethod: auth.method, dialect, + connectorId: target.connectorId, + extend, }; } /** - * Handshake. + * Handshake, jednou na token. * - * Bez nej server dalsi volani odmitne. Soucasti je i notifikace - * `notifications/initialized` - tou klient rika, ze je pripraven, a teprve - * pak smi volat nastroje. + * Server drzi sezeni u tokenu, takze po jeho vymene se musi zopakovat. Delat + * ho pred kazdym volanim by byla dve volani navic pokazde. + * + * Vraci jmeno serveru, kdyz handshake probehl. Prazdny retezec znamena, ze uz + * probehl driv a jmeno tedy znovu neprislo. */ -async function handshake(session: Session, signal: AbortSignal): Promise { +async function ensureInitialized(session: Session, signal: AbortSignal): Promise { + const key = session.connectorId ?? session.url.toString(); + if (session.authHeader !== '' && initialized.get(key) === session.authHeader) return ''; + const result = (await rpc( session, 'initialize', { protocolVersion: session.dialect.protocolVersion, capabilities: {}, clientInfo: CLIENT_INFO }, signal, - )) as { protocolVersion?: string; serverInfo?: { name?: string; version?: string } }; + )) as { protocolVersion?: string; serverInfo?: { name?: string; version?: string } } | null; // Plati verze serveru. Nase je jen navrh. if (typeof result?.protocolVersion === 'string') { @@ -332,12 +429,59 @@ async function handshake(session: Session, signal: AbortSignal): Promise } await rpc(session, 'notifications/initialized', undefined, signal, false); + if (session.authHeader !== '') initialized.set(key, session.authHeader); const name = result?.serverInfo?.name ?? 'neznámý server'; const version = result?.serverInfo?.version; return version ? `${name} ${version}` : name; } +/** Zapomene handshake. Po odmitnutem tokenu se musi udelat znovu. */ +function forgetHandshake(session: Session): void { + initialized.delete(session.connectorId ?? session.url.toString()); +} + +// ------------------------------------------------------------ rozbaleni + +/** + * Rozbali obsah, ktery MCP vraci zabaleny na vic zpusobu. + * + * Odpoved byva `{content: [{type: "text", text: ""}]}`, jindy + * `{structuredContent: {...}}` a u zdroju `{contents: [...]}`. Bez rozbaleni by + * v kroku skoncil JSON zapsany jako text, se kterym uz se v podmince nic + * nesvede. + * + * `structuredContent` ma prednost, protoze to je cast, kterou sam server + * oznacil za strukturovanou. + */ +function deep(value: unknown, depth = 0): unknown { + if (depth > 6) return value; + + if (typeof value === 'string') { + const text = value.trim(); + if (text.startsWith('{') || text.startsWith('[')) { + try { + return deep(JSON.parse(text), depth + 1); + } catch { + return value; + } + } + return value; + } + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map((item) => deep(item, depth + 1)); + + const record = value as Record; + if (record.structuredContent !== undefined) return deep(record.structuredContent, depth + 1); + if (Array.isArray(record.contents)) return deep(record.contents, depth + 1); + if (Array.isArray(record.content)) { + const unwrapped = record.content.map((item) => deep(item, depth + 1)); + return unwrapped.length === 1 ? unwrapped[0] : unwrapped; + } + if (typeof record.text === 'string') return deep(record.text, depth + 1); + return record; +} + /** 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; @@ -360,14 +504,15 @@ function toTool(value: unknown): McpTool | null { }; } +// ------------------------------------------------------------------ obal + /** * Obal, ktery z vyjimky udela vysledek. Ven z tohoto souboru nic nevyhazuje. * - * Zaklada sezeni vcetne prihlaseni a resi jednu vec navic: **kdyz server - * odmitne token, ktery jsme povazovali za platny, zahodi se a zkusi se to - * jeste jednou**. Stava se to, kdyz nekdo na druhe strane odebere opravneni - * driv, nez token vyprsel. Podruhe uz se to neopakuje - to uz nejsou - * pristupove udaje, ktere by sedely. + * Resi jednu vec navic: **kdyz server odmitne token nebo sezeni, ktere jsme + * povazovali za platne, prihlasi se znovu a zkusi to jeste jednou**. Stava se + * to, kdyz nekdo na druhe strane odebral opravneni driv, nez token vyprsel. + * Podruhe uz se to neopakuje - to uz nejsou udaje, ktere by sedely. */ async function attempt( target: ResolvedTarget, @@ -383,12 +528,21 @@ async function attempt( } const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), config.scriptTimeoutMs); + let timer = setTimeout(() => controller.abort(), config.scriptTimeoutMs); + /* + * Limit celeho volani jde posunout. Bezne volani ma na odpoved patnact + * sekund, ale nastroj spusteny jako uloha bezi minuty - a to neni zadrhel, + * to je jeho normalni chovani. + */ + const extend = (ms: number): void => { + clearTimeout(timer); + timer = setTimeout(() => controller.abort(), ms); + }; const signal = controller.signal; try { - for (let attemptNumber = 0; ; attemptNumber += 1) { - const session = await buildSession(target, signal); + for (let round = 0; ; round += 1) { + const session = await buildSession(target, signal, round > 0, extend); try { const value = await run(session, signal); return { @@ -400,9 +554,9 @@ async function attempt( value, }; } catch (err) { - const rejected = err instanceof McpFailure && err.status === 401; - if (rejected && attemptNumber === 0 && session.authMethod !== 'bez přihlášení') { - forgetToken(target.connectorId); + const lost = err instanceof McpFailure && err.sessionLost; + if (lost && round === 0 && session.authMethod !== 'bez přihlášení') { + forgetHandshake(session); continue; } throw err; @@ -411,9 +565,9 @@ async function attempt( } catch (err) { const failure = err instanceof McpFailure ? err : null; const auth = err instanceof AuthFailure ? err : null; - // Redakce jen z udaju konektoru: token uz je zahozeny a v hlasce byt nemuze. const redact = createRedactor(targetSecrets(target)); - const message = failure ?? auth ? (failure ?? auth)!.message : err instanceof Error ? err.message : String(err); + const reported = failure ?? auth; + const message = reported ? reported.message : err instanceof Error ? err.message : String(err); return { ok: false, message: redact(message), @@ -427,47 +581,72 @@ async function attempt( } } +// --------------------------------------------------------------- seznamy + +/** + * Nacte cely seznam, i kdyz ho server posila po strankach. + * + * Bez tohohle je videt **jen prvni stranka**, coz u nastroju byva dvacet + * polozek. Vypada to jako uplny seznam, takze se na to prijde az ve chvili, + * kdy nekdo hleda nastroj, ktery na serveru je. + */ +async function stitch( + session: Session, + method: string, + key: string, + signal: AbortSignal, +): Promise { + const items: unknown[] = []; + let cursor: string | undefined; + + for (let page = 0; page < MAX_PAGES; page += 1) { + const result = (await rpc(session, method, cursor ? { cursor } : {}, signal)) as Record< + string, + unknown + > | null; + if (!result) break; + + const batch = result[key]; + if (Array.isArray(batch)) items.push(...batch); + + const next = typeof result.nextCursor === 'string' ? result.nextCursor : undefined; + // Stejny kurzor podruhe by znamenal nekonecnou smycku. + if (!next || next === cursor) break; + cursor = next; + } + return items; +} + /** * 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. + * i prihlaseni sedi. Nic se pri tom nemeni, takze to jde spustit kdykoliv. */ export function listTools(target: ResolvedTarget): Promise> { return attempt( target, async (session, signal) => { - const server = await handshake(session, signal); + const server = await ensureInitialized(session, signal); + const rows = await stitch(session, 'tools/list', 'tools', 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; + for (const row of rows) { + const tool = toTool(row); + if (tool) tools.push(tool); } const toolset: McpToolset = { at: new Date().toISOString(), - server, + // Kdyz uz handshake probehl driv, jmeno serveru znovu neprijde. + server: server === '' ? 'server' : server, protocolVersion: session.protocolVersion, tools: tools.sort((a, b) => a.name.localeCompare(b.name, 'cs')), }; return toolset; }, - // Zpusob prihlaseni patri do hlasky: uzivatel vyplnil jmeno a heslo a ma - // vedet, jak s nimi portal nalozil, nez zacne hledat chybu jinde. + // Zpusob prihlaseni patri do hlasky: uzivatel vyplnil udaje a ma vedet, + // jak s nimi portal nalozil, nez zacne hledat chybu jinde. (value, session) => (value.tools.length === 0 ? `Server ${value.server} odpověděl, ale žádný nástroj nenabízí.` @@ -476,6 +655,137 @@ export function listTools(target: ResolvedTarget): Promise name.toLowerCase()); + for (const [key, value] of Object.entries(source as Record)) { + if (wanted.includes(key.toLowerCase())) return value; + } + return undefined; +} + +function delay(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener( + 'abort', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); +} + +/** Prevede odpoved serveru na vysledek kroku. */ +function toCallResult(result: Record | null, taskId: string | null): McpCallResult { + const content = result?.content; + const text = Array.isArray(content) + ? content + .filter((block) => { + if (block === null || typeof block !== 'object') return false; + const row = block as Record; + return row.type === 'text' && typeof row.text === 'string'; + }) + .map((block) => String((block as Record).text)) + .join('\n') + : ''; + + const data = result === null ? null : deep(result); + const structuredContent = result?.structuredContent; + const structured = + structuredContent !== null && typeof structuredContent === 'object' + ? (structuredContent as Record) + : data !== null && typeof data === 'object' && !Array.isArray(data) + ? (data as Record) + : null; + + return { text, structured, data, isError: result?.isError === true, taskId }; +} + +/** + * Nastroj, ktery bezi dlouho, se spousti jako uloha. + * + * Server u nej odmitne obycejne volani a ceka, ze si o ulohu reknete zvlast. + * Na jeji konec se pak **ceka dotazovanim**, protoze stav z `tasks/list` + * a `tasks/get` je to jedine, co je vzdycky pravda - notifikace o prubehu + * chodi nejvys jednou a mohou se minout. + * + * Kdyz uloha nedobehne do naseho stropu, **neni to chyba**: na serveru bezi + * dal a krok to rekne misto toho, aby predstiral selhani. + */ +async function runTask( + session: Session, + name: string, + args: Record, + signal: AbortSignal, +): Promise { + session.extend(TASK_WAIT_MS); + + const started = (await rpc( + session, + 'tools/call', + { name, arguments: args, task: { ttl: 3_600_000 } }, + signal, + )) as Record | null; + + const task = pick(started, 'task'); + const taskId = pick(task, 'taskId'); + if (typeof taskId !== 'string' || taskId === '') { + throw new McpFailure(`Nástroj ${name} se měl spustit jako úloha, ale server nevrátil její ID.`); + } + + const interval = pick(task, 'pollInterval'); + const wait = Math.max(typeof interval === 'number' ? interval : TASK_POLL_MS, 1_000); + const deadline = Date.now() + TASK_WAIT_MS; + + /* + * Dokoncena uloha ze seznamu mizi, takze "neni v seznamu" neznamena "bezi". + * Po nekolika marnych kolech se prejde na primy dotaz, ktery zna i ulohy, + * ktere uz ze seznamu vypadly. + */ + let missing = 0; + for (;;) { + await delay(wait, signal); + if (signal.aborted || Date.now() > deadline) break; + + const list = (await rpc(session, 'tasks/list', {}, signal)) as Record | null; + const tasks = pick(list, 'tasks'); + const found = Array.isArray(tasks) + ? tasks.find((item) => pick(item, 'taskId') === taskId) + : undefined; + + if (!found) { + missing += 1; + if (missing < 3) continue; + break; + } + missing = 0; + + const status = pick(found, 'status'); + if (typeof status === 'string' && status.toLowerCase() !== 'working') break; + } + + // Chvile navic, aby dobehlo posledni hlaseni o prubehu. + await delay(300, signal); + + const result = (await rpc(session, 'tasks/get', { taskId }, signal)) as Record< + string, + unknown + > | null; + return toCallResult(result, taskId); +} + /** Zavola jeden nastroj. `args` uz musi byt v typech, ktere schema chce. */ export function callTool( target: ResolvedTarget, @@ -485,28 +795,33 @@ export function callTool( return attempt( target, async (session, signal) => { - await handshake(session, signal); - const result = (await rpc(session, 'tools/call', { name, arguments: args }, signal)) as { - content?: Array>; - structuredContent?: Record; - isError?: boolean; - }; + await ensureInitialized(session, signal); - 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; + try { + const result = (await rpc(session, 'tools/call', { name, arguments: args }, signal)) as + | Record + | null; + return toCallResult(result, null); + } catch (err) { + /* + * Server rika, ze tenhle nastroj se takhle spustit neda a chce ho jako + * ulohu. Neni to chyba nastaveni, je to jeho zpusob, jak rict "tohle + * bezi dlouho". + */ + const needsTask = + err instanceof McpFailure && + err.code === TASK_REQUIRED_CODE && + /task/i.test(`${err.message}${err.detail ?? ''}`); + if (!needsTask) throw err; + return runTask(session, name, args, signal); + } + }, + (value, session) => { + const how = value.taskId ? ' jako úloha' : ''; + const done = value.isError + ? `Nástroj ${name} skončil chybou${how}.` + : `Nástroj ${name} doběhl${how}.`; + return `${done} Přihlášení: ${session.authMethod}.`; }, - (value) => (value.isError ? `Nástroj ${name} skončil chybou.` : `Nástroj ${name} doběhl.`), ); } diff --git a/src/mcp/dialect.ts b/src/mcp/dialect.ts index b392a03..30c60d7 100644 --- a/src/mcp/dialect.ts +++ b/src/mcp/dialect.ts @@ -40,8 +40,8 @@ export interface McpDialect { /** * Prijme klient odpoved jako SSE stream? * - * EasyWeb ma SSE zatim jen jako zakomentovany kod, takze mu nema smysl - * rikat, ze stream umime. + * Obojí je vzdycky lepsi nez jen JSON: server si vybira sam a nabidnout mu + * jen jednu moznost znamena, ze cast serveru pozadavek odmitne. */ acceptEventStream: boolean; /** @@ -64,7 +64,9 @@ const dialects: Record = { auth: 'easyweb', // Server si po handshaku kontroluje, ze hlavicka sedi na jeho konstantu. protocolVersion: '2025-11-25', - acceptEventStream: false, + // Server si sam vybira a streamem odpovida i na obycejna volani. + acceptEventStream: true, + // Sezeni drzi u tokenu, hlavicku nevydava. useSessionHeader: false, }, }; diff --git a/src/mcp/easyweb/crypto.ts b/src/mcp/easyweb/crypto.ts new file mode 100644 index 0000000..1a77b95 --- /dev/null +++ b/src/mcp/easyweb/crypto.ts @@ -0,0 +1,102 @@ +/** + * Kryptografie pro prihlaseni k EasyWebu. + * + * Server nevydava token proti jmenu a heslu, ale proti **zarizeni**. Zarizeni + * je pár klicu ECDSA P-256 a kazde volani, ktere s tokeny hybe, se jim podepisuje + * (proof of possession). Jmeno a heslo se pouziji jedinkrat, pri registraci + * klice. + * + * Tenhle soubor umi jen ty ctyri veci, ktere na to jsou potreba: vyrobit klic, + * ulozit a nacist ho, podepsat text a spocitat otisk. Nic z toho nevi, co je + * MCP. + * + * **Podpis musi byt v tvaru P1363**, tedy holé `r || s`, 64 bajtu. Protistrana + * ho overuje pres WebCrypto a ta jiny tvar nezna - DER, ktery Node pouziva + * jako vychozi, by neprosel a hlaska by mluvila o spatnem podpisu, ne o tvaru. + */ + +import crypto, { type KeyObject } from 'node:crypto'; + +/** Retezec, kterym zacina podpis pri registraci klice. Dane serverem. */ +export const KEY_REGISTRATION_CONTEXT = 'CELEBRUM-MCP-KEY-REGISTRATION-V1'; + +/** Retezec, kterym zacina podpis pri obnove tokenu. Dane serverem. */ +export const POP_CONTEXT = 'CELEBRUM-MCP-POP-V1'; + +/** Klic zarizeni tak, jak se s nim pracuje za behu. */ +export interface DeviceKey { + privateKey: KeyObject; + /** Verejny klic v DER. Posila se pri registraci jako base64url. */ + spkiDer: Buffer; + /** base64url(SHA-256(spkiDer)). Soucast podpisu pri registraci. */ + thumbprint: string; +} + +/** Vyrobi novy par klicu. Dela se **jednou za konektor**, pak uz se jen nacita. */ +export function generateDeviceKey(): { key: DeviceKey; jwk: string } { + const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); + return { key: fromPrivateKey(privateKey), jwk: JSON.stringify(privateKey.export({ format: 'jwk' })) }; +} + +/** + * Nacte klic z ulozeneho JWK. + * + * Vraci null, kdyz je zaznam poskozeny. Novy klic se pak vyrobi a zaregistruje + * znovu - je to lepsi nez spadnout, protoze bez klice se nejde prihlasit vubec. + */ +export function deviceKeyFromJwk(jwk: string): DeviceKey | null { + try { + const privateKey = crypto.createPrivateKey({ key: JSON.parse(jwk), format: 'jwk' }); + return fromPrivateKey(privateKey); + } catch { + return null; + } +} + +function fromPrivateKey(privateKey: KeyObject): DeviceKey { + const spkiDer = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' }); + return { + privateKey, + spkiDer, + thumbprint: base64url(crypto.createHash('sha256').update(spkiDer).digest()), + }; +} + +/** + * Podpis textu klicem zarizeni. + * + * `dsaEncoding: 'ieee-p1363'` neni detail. Bez nej Node podepisuje do DER + * a protistrana ceka holé `r || s`. + */ +export function sign(key: DeviceKey, text: string): string { + const signature = crypto.sign('sha256', Buffer.from(text, 'utf8'), { + key: key.privateKey, + dsaEncoding: 'ieee-p1363', + }); + return base64url(signature); +} + +/** Podpis nad radky spojenymi novym radkem. Poradi urcuje server. */ +export function signLines(key: DeviceKey, lines: string[]): string { + return sign(key, lines.join('\n')); +} + +/** base64url(SHA-256(token)). Server tim rika, ktery token se obnovuje. */ +export function tokenHash(token: string): string { + return base64url(crypto.createHash('sha256').update(token, 'utf8').digest()); +} + +/** Nahodne bajty jako base64url. Pouziva se na nonce pri registraci. */ +export function randomNonce(bytes = 32): string { + return base64url(crypto.randomBytes(bytes)); +} + +/** Nahodne bajty jako hex. Pouziva se na `RequestId` pri obnove. */ +export function randomHex(bytes = 16): string { + return crypto.randomBytes(bytes).toString('hex'); +} + +/** Base64 bez odsazeni a s `-_` misto `+/`, tak jak to chce server. */ +export function base64url(value: Buffer): string { + return value.toString('base64url'); +} diff --git a/src/mcp/easyweb/device.ts b/src/mcp/easyweb/device.ts new file mode 100644 index 0000000..b58b807 --- /dev/null +++ b/src/mcp/easyweb/device.ts @@ -0,0 +1,87 @@ +/** + * Identita zarizeni pro MCP EasyWebu. + * + * Server nevydava tokeny uctu, ale **zarizeni**. Zarizeni je pár klicu, ktery + * se jednou zaregistruje jmenem a heslem a od te chvile uz je identitou sam. + * Jmeno a heslo se pak nepouziva. + * + * Z toho plynou dve veci, ktere musi platit, jinak server ucet zablokuje: + * + * - **Klic prezije restart**, jinak by se pri kazdem startu registrovalo nove + * zarizeni. Uklada se mezi udaje konektoru, tedy zasifrovane, protoze je to + * privatni klic. + * - **Jeden konektor je jedno zarizeni.** Dve napojeni tehoz portalu na tentyz + * server maji ruzne klice a ruzne tokeny. Sdilet je znamena, ze si navzajem + * spotrebuji jednorazove tokeny. + * + * Otisk (`fingerprint`) je jmeno zarizeni v ocich serveru. Musi byt stabilni + * a **parovany prave s timhle klicem**, proto se vyrabi spolu s nim a uklada + * se vedle nej. + */ + +import { getConnector, setManagedValues } from '../../data/connectorStore.js'; +import { deviceKeyFromJwk, generateDeviceKey, type DeviceKey } from './crypto.js'; + +/** ID pole konektoru, do ktereho se uklada klic. Spravuje ho portal. */ +export const DEVICE_JWK_FIELD = 'deviceJwk'; + +/** ID pole konektoru s otiskem zarizeni. */ +export const DEVICE_FINGERPRINT_FIELD = 'deviceFingerprint'; + +/** + * Zacatek otisku. + * + * Referencni klient EasyWebu si tvori otisk jako `cst-agents-conn-...`. Tvar je + * jeho, ne pozadavek serveru - je to jmeno, ktere ma byt stabilni. Pouzivame + * proto svoje, aby v logu serveru bylo poznat, ze se hlasi WorkNuke, a ne + * abychom se vydavali za jejich most. + * + * Kdyby server prefix presto kontroloval, meni se to tady. + */ +const FINGERPRINT_PREFIX = 'worknuke-conn'; + +export interface Device { + key: DeviceKey; + fingerprint: string; +} + +/** + * Klic zarizeni pro konektor. Pri prvnim volani ho vyrobi a ulozi. + * + * Zapis jde pres `setManagedValues`, ne pres bezny zapis udaju: ten + * znehodnocuje overeni konektoru, kdezto tohle se deje **pri** uspesnem + * prihlaseni a shodilo by stav na neovereny hned potom, co se overil. + */ +export async function deviceFor(connectorId: string, tenantId: string): Promise { + const connector = await getConnector(connectorId, [tenantId]); + if (!connector) throw new Error(`Konektor ${connectorId} v této firmě není.`); + + const storedJwk = (connector.values[DEVICE_JWK_FIELD] ?? '').trim(); + const storedFingerprint = (connector.values[DEVICE_FINGERPRINT_FIELD] ?? '').trim(); + + if (storedJwk !== '' && storedFingerprint !== '') { + const key = deviceKeyFromJwk(storedJwk); + // Poskozeny zaznam neni duvod spadnout - vyrobi se novy klic. Server pak + // uvidi nove zarizeni, coz je porad lepsi nez napojeni, ktere nejde pouzit. + if (key) return { key, fingerprint: storedFingerprint }; + console.warn(`[mcp] ${connectorId}: ulozeny klic zarizeni je poskozeny, delam novy`); + } + + const { key, jwk } = generateDeviceKey(); + /* + * Nahodny konec otisku je tam kvuli tomu, aby nove zarizeni po ztrate klice + * nemelo tentyz otisk jako to stare. Server by jinak videl znamy otisk + * s jinym klicem, a to je presne obraz pokusu o podvrzeni. + */ + const suffix = key.thumbprint.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8).toLowerCase(); + const fingerprint = `${FINGERPRINT_PREFIX}-${connectorId}-${suffix}`; + + await setManagedValues( + connectorId, + { [DEVICE_JWK_FIELD]: jwk, [DEVICE_FINGERPRINT_FIELD]: fingerprint }, + [tenantId], + ); + console.info(`[mcp] ${connectorId}: vyrobeno nove zarizeni ${fingerprint}`); + + return { key, fingerprint }; +} diff --git a/src/mcp/easyweb/session.ts b/src/mcp/easyweb/session.ts new file mode 100644 index 0000000..a40a0f3 --- /dev/null +++ b/src/mcp/easyweb/session.ts @@ -0,0 +1,430 @@ +/** + * Prihlaseni k MCP EasyWebu (auth v2). + * + * Server vydava tri tokeny a kazdy ma jinou roli: + * + * | Token | K cemu | Zivotnost | + * | -------- | ----------------------------------------------- | --------- | + * | pristupovy | posila se s kazdym volanim MCP | ~30 minut | + * | obnovovaci | vymeni pristupovy za novy | delsi | + * | zarizeni | vymeni obnovovaci, kdyz uz neplati | nejdelsi | + * + * Pravidla, ktera se nesmi porusit, protoze **server umi zablokovat zarizeni**: + * + * - **Tokeny jsou jednorazove.** Po obnove je stary spotrebovany a druhe + * pouziti server odmitne kodem 409. Proto se drzi vzdy jen ten nejnovejsi + * a vymena je atomicka. + * - **Jedno prihlaseni naraz.** Dve soubezna volani nad tim samym konektorem + * nesmi spustit dve obnovy - druha by pracovala se spotrebovanym tokenem. + * Resi to jedna sdilena rozdelana operace na konektor. + * - **Tokeny nikam nezapisovat.** Po restartu je stary token uz neplatny + * a jeho pouziti vypada jako pokus o zneuziti. Prezije jen klic zarizeni. + * + * Kazde volani, ktere s tokeny hybe, se podepisuje klicem zarizeni. Jmeno + * a heslo se pouziji **jen jednou**, pri registraci klice. + */ + +import { config } from '../../config.js'; +import { truncate } from '../../scripts/util.js'; +import { + KEY_REGISTRATION_CONTEXT, + POP_CONTEXT, + base64url, + randomHex, + randomNonce, + signLines, + tokenHash, +} from './crypto.js'; +import { deviceFor, type Device } from './device.js'; + +/** + * Rezerva pred vyprsenim pristupoveho tokenu. + * + * Referencni klient obnovuje pri zbylych 90 sekundach. Kratsi rezerva znamena, + * ze token muze vyprset po ceste, delsi jen obnovuje casteji. + */ +const RENEW_MARGIN_MS = 90_000; + +/** Jak dlouho token plati, kdyz to z nej nejde vycíst. Odhad smerem dolu. */ +const FALLBACK_LIFETIME_MS = 300_000; + +/** Chyba prihlaseni. Vetu cte uzivatel, proto zadny stack. */ +export class EasyWebAuthError extends Error { + constructor( + message: string, + readonly status: number | null = null, + readonly detail: string | null = null, + ) { + super(message); + this.name = 'EasyWebAuthError'; + } +} + +export interface EasyWebCredentials { + connectorId: string; + tenantId: string; + /** Adresa MCP serveru, tedy `mcpRoot`. */ + serverUrl: string; + username: string; + password: string; + /** Nazev, pod kterym je zarizeni videt v logu serveru. */ + deviceName: string; + clientInfo: string; +} + +interface Tokens { + accessToken: string; + accessExpiresAt: number; + refreshToken: string; + refreshNonce: string; + deviceToken: string; + deviceNonce: string; +} + +interface Entry { + tokens: Tokens | null; + /** Rozdelane prihlaseni. Druhy volajici pocka, misto aby zacal svoje. */ + inflight: Promise | null; +} + +/** Stav podle konektoru. Dva konektory jsou dve zarizeni, nic nesdili. */ +const sessions = new Map(); + +function entryFor(connectorId: string): Entry { + let entry = sessions.get(connectorId); + if (!entry) { + entry = { tokens: null, inflight: null }; + sessions.set(connectorId, entry); + } + return entry; +} + +// ------------------------------------------------------------------ volani + +/** Odpoved serveru jako objekt. Klice cte necitlive, .NET je pise velkymi. */ +function fieldOf(body: Record, ...names: string[]): string | null { + const wanted = names.map((name) => name.toLowerCase()); + for (const [key, value] of Object.entries(body)) { + if (!wanted.includes(key.toLowerCase())) continue; + if (typeof value === 'string' && value.trim() !== '') return value.trim(); + } + return null; +} + +/** + * Kdy vyprsi pristupovy token. + * + * Cte se `exp` z tela JWT. Server zivotnost jinak nikam nepise, takze bez + * tohohle by se token drzel do prvniho odmitnuti - a to uz je zbytecne volani + * navic u kazdeho kroku. + */ +function accessExpiry(token: string): number { + const parts = token.split('.'); + if (parts.length >= 2) { + try { + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as { + exp?: unknown; + }; + if (typeof payload.exp === 'number') return payload.exp * 1000; + } catch { + // Neni to JWT. Plati odhad nize. + } + } + return Date.now() + FALLBACK_LIFETIME_MS; +} + +function endpoint(serverUrl: string, path: string): string { + return `${serverUrl.replace(/\/+$/, '')}/${path}`; +} + +/** + * Jedno volani na prihlasovaci endpoint. + * + * 401 a 409 se **nevyhazuji jako koncova chyba**: obojí znamena "tenhle token + * uz neplati" a nasleduje dalsi clanek retezu. 409 je navic to, cim server + * rika, ze token uz nekdo spotreboval. + */ +async function post( + url: string, + body: Record, + headers: Record, + signal: AbortSignal, +): Promise<{ ok: boolean; status: number; body: Record | null; detail: string }> { + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/json; charset=utf-8', Accept: 'application/json', ...headers }, + body: JSON.stringify(body), + }); + } catch (err) { + const name = err instanceof Error ? err.name : ''; + if (name === 'AbortError' || name === 'TimeoutError') { + throw new EasyWebAuthError(`Server ${url} neodpověděl v limitu.`); + } + throw new EasyWebAuthError( + `Nepodařilo se spojit s ${url}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const raw = await response.text(); + const detail = truncate(raw, config.errorDetailBytes); + + let parsed: Record | null = null; + if (raw.trim() !== '') { + try { + const value: unknown = JSON.parse(raw); + if (value !== null && typeof value === 'object') parsed = value as Record; + } catch { + parsed = null; + } + } + + return { ok: response.ok, status: response.status, body: parsed, detail }; +} + +/** Slozi tokeny z odpovedi. Chybejici pristupovy token je chyba. */ +function tokensFrom( + body: Record | null, + previous: Tokens | null, + where: string, +): Tokens { + const accessToken = body ? fieldOf(body, 'AccessToken') : null; + if (!accessToken) { + throw new EasyWebAuthError(`Odpověď z ${where} neobsahuje přístupový token.`); + } + + /* + * Co odpoved neposlala, plati z minula. `renew-access` vraci jen pristupovy + * a obnovovaci token, token zarizeni zustava - prepsat ho prazdnou hodnotou + * by znamenalo, ze pri dalsi obnove neni cim. + */ + return { + accessToken, + accessExpiresAt: accessExpiry(accessToken), + refreshToken: (body ? fieldOf(body, 'RefreshToken') : null) ?? previous?.refreshToken ?? '', + refreshNonce: (body ? fieldOf(body, 'RefreshTokenNonce') : null) ?? previous?.refreshNonce ?? '', + deviceToken: (body ? fieldOf(body, 'DeviceToken') : null) ?? previous?.deviceToken ?? '', + deviceNonce: (body ? fieldOf(body, 'DeviceTokenNonce') : null) ?? previous?.deviceNonce ?? '', + }; +} + +// ------------------------------------------------------- clanky retezu + +/** + * Registrace zarizeni jmenem a heslem. + * + * Jedine volani, ktere pouziva jmeno a heslo. Soucasti je podpis, kterym + * zarizeni dokazuje, ze privatni klic k poslanemu verejnemu opravdu ma. + */ +async function login( + credentials: EasyWebCredentials, + device: Device, + signal: AbortSignal, +): Promise { + const url = endpoint(credentials.serverUrl, 'login'); + const nonce = randomNonce(); + const proof = signLines(device.key, [ + KEY_REGISTRATION_CONTEXT, + credentials.username.trim(), + device.fingerprint, + device.key.thumbprint, + nonce, + ]); + + const basic = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); + const result = await post( + url, + { + Name: credentials.deviceName, + Fingerprint: device.fingerprint, + ClientInfo: credentials.clientInfo, + PublicKey: base64url(device.key.spkiDer), + PublicKeyAlgorithm: 'ES256', + KeyProofNonce: nonce, + KeyProof: proof, + }, + { Authorization: `Basic ${basic}` }, + signal, + ); + + if (!result.ok) { + throw new EasyWebAuthError( + result.status === 401 || result.status === 400 + ? 'Server přihlášení nepřijal. Ověřte jméno a heslo a to, že účet na tomhle ' + + 'serveru existuje a smí zakládat zařízení.' + : `Přihlášení na ${url} vrátilo HTTP ${result.status}.`, + result.status, + result.detail === '' ? null : result.detail, + ); + } + return tokensFrom(result.body, null, url); +} + +/** Vysledek clanku retezu. `null` = tenhle zpusob neprosel, zkusi se dalsi. */ +type Attempt = Tokens | null; + +/** + * Obnova pristupoveho tokenu obnovovacim. + * + * **Obnovovaci token pri tom rotuje** - odpoved nese novy a stary uz neplati. + * Ulozit se musi oba, jinak dalsi obnova narazi na spotrebovany token. + */ +async function renewAccess( + credentials: EasyWebCredentials, + device: Device, + tokens: Tokens, + signal: AbortSignal, +): Promise { + if (tokens.refreshToken === '') return null; + + const url = endpoint(credentials.serverUrl, 'renew-access-token'); + const requestId = randomHex(); + const proof = signLines(device.key, [ + POP_CONTEXT, + 'renew-access', + tokenHash(tokens.refreshToken), + tokens.refreshNonce, + requestId, + ]); + + const result = await post( + url, + { + RefreshToken: tokens.refreshToken, + Nonce: tokens.refreshNonce, + RequestId: requestId, + DeviceProof: proof, + }, + {}, + signal, + ); + + // 401 i 409 znamenaji "tenhle token uz neplati", pokracuje se dal retezem. + if (result.status === 401 || result.status === 409) return null; + if (!result.ok) { + throw new EasyWebAuthError( + `Obnova přístupu na ${url} vrátila HTTP ${result.status}.`, + result.status, + result.detail === '' ? null : result.detail, + ); + } + return tokensFrom(result.body, tokens, url); +} + +/** Obnova obnovovaciho tokenu tokenem zarizeni. Vraci celou sadu. */ +async function renewRefresh( + credentials: EasyWebCredentials, + device: Device, + tokens: Tokens, + signal: AbortSignal, +): Promise { + if (tokens.deviceToken === '') return null; + + const url = endpoint(credentials.serverUrl, 'renew-refresh-token'); + const requestId = randomHex(); + const proof = signLines(device.key, [ + POP_CONTEXT, + 'renew-refresh', + tokenHash(tokens.deviceToken), + tokens.deviceNonce, + requestId, + ]); + + const result = await post( + url, + { + DeviceToken: tokens.deviceToken, + Nonce: tokens.deviceNonce, + RequestId: requestId, + DeviceProof: proof, + }, + {}, + signal, + ); + + if (result.status === 401 || result.status === 409) return null; + if (!result.ok) { + throw new EasyWebAuthError( + `Obnova zařízení na ${url} vrátila HTTP ${result.status}.`, + result.status, + result.detail === '' ? null : result.detail, + ); + } + return tokensFrom(result.body, tokens, url); +} + +// ------------------------------------------------------------------- ven + +/** + * Platny pristupovy token. + * + * Retez s ustupy: platny token, obnova obnovovacim, obnova zarizenim, cele + * prihlaseni. Kazdy dalsi clanek je drazsi, proto se jde odshora. + * + * `force` zahodi i token, ktery jeste plati. Pouziva se po tom, co ho server + * odmitl - to znamena, ze uz neplati, at si o nem myslime cokoliv. + */ +export async function ensureAccess( + credentials: EasyWebCredentials, + signal: AbortSignal, + force = false, +): Promise { + const entry = entryFor(credentials.connectorId); + + if (!force && entry.tokens && Date.now() < entry.tokens.accessExpiresAt - RENEW_MARGIN_MS) { + return entry.tokens.accessToken; + } + + /* + * Jedno prihlaseni naraz. Bez tohohle by dve soubezne automatizace nad tim + * samym konektorem spustily dve obnovy, druha by pracovala se spotrebovanym + * tokenem a server by zarizeni zablokoval. + */ + if (entry.inflight) { + const tokens = await entry.inflight; + if (!force) return tokens.accessToken; + } + + const run = (async (): Promise => { + const device = await deviceFor(credentials.connectorId, credentials.tenantId); + const current = entry.tokens; + + if (current) { + const renewed = await renewAccess(credentials, device, current, signal); + if (renewed) return renewed; + + const restored = await renewRefresh(credentials, device, current, signal); + if (restored) return restored; + + console.warn( + `[mcp] ${credentials.connectorId}: obnovovaci token ani token zarizeni neprosly, hlasim se znovu`, + ); + } + + return login(credentials, device, signal); + })(); + + entry.inflight = run; + try { + const tokens = await run; + // Vymena je atomicka: bud plati cela nova sada, nebo zustava stara. + entry.tokens = tokens; + return tokens.accessToken; + } finally { + if (entry.inflight === run) entry.inflight = null; + } +} + +/** + * Zahodi tokeny konektoru. + * + * Vola se, kdyz server odmitne token, ktery jsme povazovali za platny. + * Zarizeni zustava, prihlasi se znovu jeho klicem. + */ +export function forgetSession(connectorId: string): void { + const entry = sessions.get(connectorId); + if (!entry) return; + entry.tokens = null; +} diff --git a/src/scripts/connections.ts b/src/scripts/connections.ts index 3cf75bf..257b7b3 100644 --- a/src/scripts/connections.ts +++ b/src/scripts/connections.ts @@ -19,6 +19,14 @@ import { findService, type Service } from '../data/services.js'; export interface ResolvedTarget { serviceId: string; connectorId: string | null; + /** + * Firma, ktere konektor patri. `null` u obecnych sluzeb bez napojeni. + * + * Je tu proto, ze nektera napojeni si za behu **zapisuji vlastni udaje** + * zpatky ke konektoru - klic zarizeni u MCP EasyWebu. Zapis do uloziste + * potrebuje filtr na firmu a ten se nesmi dohadovat. + */ + tenantId: string | null; /** Prazdne u obecnych sluzeb - `ctx.http` pak vyzaduje absolutni adresu. */ baseUrl: string; /** Vcetne tajemstvi. Nikdy neposilat na klienta ani do logu. */ @@ -85,6 +93,7 @@ export function resolveTarget(serviceId: string, connector: Connector | null): R return { serviceId, connectorId: connector?.id ?? null, + tenantId: connector?.tenantId ?? null, baseUrl: '', headers: {}, secretValues: [], @@ -131,6 +140,7 @@ export function resolveTarget(serviceId: string, connector: Connector | null): R return { serviceId, connectorId: connector?.id ?? null, + tenantId: connector?.tenantId ?? null, baseUrl: connector?.baseUrl ?? serviceBaseUrl(service), headers, secretValues, diff --git a/web/src/pages/dashboard/Connectors.tsx b/web/src/pages/dashboard/Connectors.tsx index 5b3055c..8344de0 100644 --- a/web/src/pages/dashboard/Connectors.tsx +++ b/web/src/pages/dashboard/Connectors.tsx @@ -754,6 +754,8 @@ function ConnectorEditor({ const [issues, setIssues] = useState>([]); const service = servicesById.get(serviceId) ?? services.find((item) => item.id === serviceId); + // Spravovana pole vyplnuje portal, ne clovek. Do formulare nepatri. + const visibleCredentials = (service?.credentials ?? []).filter((field) => !field.managed); // Pri otevreni dialogu se formular naplni podle toho, co se upravuje. // Zavislost je na `editor`, ne na `service` - jinak by prepnuti sluzby @@ -906,9 +908,13 @@ function ConnectorEditor({ - {service && service.credentials.length > 0 ? ( + {/* + Pole, ktera spravuje portal (klic zarizeni u MCP EasyWebu), se + nezobrazuji. Uzivatel do nich nema co psat a prepsat by je mohl. + */} + {service && visibleCredentials.length > 0 ? (
- {service.credentials.map((field) => ( + {visibleCredentials.map((field) => (