MCP EasyWeb podle skutecne specifikace: auth v2 s klicem zarizeni
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 par klicu ECDSA P-256. Jmeno a heslo se pouziji jedinkrat, pri registraci klice, 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 hole r||s, 64 bajtu. Node podepisuje ve vychozim nastaveni do DER a ten by protistrana neuznala - src/mcp/easyweb/device.ts - klic se vyrobi jednou a prezije restart, uklada se mezi udaje konektoru, ktere uz jsou zasifrovane. Novy priznak `managed` na poli sluzby znamena, ze ho vyplnuje portal a ve formulari se nezobrazuje - 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 pravidla nejsou opatrnost navic: tokeny jsou jednorazove, druhe pouziti server odmita kodem 409 a umi zarizeni zablokovat. Transport: - server si sam vybira, jestli odpovi JSON telem nebo SSE streamem, a streamem odpovida i na obycejna volani. Klient nabizi obojí a cte stream po kouscich - u dlouhych uloh ho server sam nezavira - handshake plati na token, ne na volani - odmitnute sezeni prijde jako chyba -32008 uvnitr uspesne odpovedi - seznamy se skladaji pres vsechny stranky, bez toho je videt jen prvni - odpoved se rozbaluje rekurzivne (structuredContent, contents, content, JSON zapsany jako text) Dlouho bezici nastroje se spousti jako uloha a ceka se na ni dotazovanim. Limit kroku se pri tom posouva z patnacti sekund na deset minut. Nedodelane: trvaly kanal notifikaci (GET SSE), nahravani souboru po castech a hlidani zmen kontraktu podle verze serveru. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d881dab30d
commit
4d156d2837
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -224,6 +224,22 @@ export function deleteConnector(id: string, tenantIds: string[]): Promise<boolea
|
||||
return repository.remove(id, tenantIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapise hodnoty, ktere spravuje portal, ne uzivatel.
|
||||
*
|
||||
* Klic zarizeni u MCP EasyWebu: vyrobi se pri prvnim prihlaseni a od te chvile
|
||||
* je to identita, kterou server pozna. Ztratit ho znamena registrovat nove
|
||||
* zarizeni, proto se uklada tam, kde jsou ostatni udaje konektoru, tedy
|
||||
* zasifrovane.
|
||||
*/
|
||||
export function setManagedValues(
|
||||
id: string,
|
||||
values: Record<string, string>,
|
||||
tenantIds: string[],
|
||||
): Promise<Connector | undefined> {
|
||||
return repository.setManagedValues(id, values, tenantIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ulozi nastroje MCP serveru ke konektoru.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ConnectorRow>(
|
||||
`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<ConnectorRow>(
|
||||
`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<ConnectorRow>(
|
||||
|
||||
@@ -183,6 +183,18 @@ export interface ConnectorRepository {
|
||||
check: ConnectorCheck | null,
|
||||
tenantIds: string[],
|
||||
): Promise<Connector | undefined>;
|
||||
/**
|
||||
* 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<string, string>,
|
||||
tenantIds: string[],
|
||||
): Promise<Connector | undefined>;
|
||||
/** Ulozi nastroje MCP serveru. Vlastni metoda, aby se nemichaly s udaji. */
|
||||
setTools(
|
||||
id: string,
|
||||
|
||||
+30
-4
@@ -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: [],
|
||||
|
||||
+49
-159
@@ -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<string, string>;
|
||||
@@ -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<Record<strin
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- 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<Session> {
|
||||
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<Session | null> {
|
||||
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<Authorization> {
|
||||
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);
|
||||
}
|
||||
|
||||
+460
-145
@@ -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<string, unknown>;
|
||||
@@ -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<string, unknown> | 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<string, string>;
|
||||
/** 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<string, string>();
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
// ------------------------------------------------------------------- SSE
|
||||
|
||||
/** Jedna zprava z bloku SSE. `null` = blok neni odpoved, ma se preskocit. */
|
||||
function messageFromBlock(block: string): Record<string, unknown> | 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<string, unknown>;
|
||||
// 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<Record<string, unknown> | 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<string, unknown> | 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<string, unknown>;
|
||||
} 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<Session> {
|
||||
// --------------------------------------------------------------- sezeni
|
||||
|
||||
async function buildSession(
|
||||
target: ResolvedTarget,
|
||||
signal: AbortSignal,
|
||||
force: boolean,
|
||||
extend: (ms: number) => void,
|
||||
): Promise<Session> {
|
||||
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<string> {
|
||||
async function ensureInitialized(session: Session, signal: AbortSignal): Promise<string> {
|
||||
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<string>
|
||||
}
|
||||
|
||||
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: "<JSON jako retezec>"}]}`, 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<string, unknown>;
|
||||
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<T>(
|
||||
target: ResolvedTarget,
|
||||
@@ -383,12 +528,21 @@ async function attempt<T>(
|
||||
}
|
||||
|
||||
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<T>(
|
||||
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<T>(
|
||||
} 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<T>(
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 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<unknown[]> {
|
||||
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<McpOutcome<McpToolset>> {
|
||||
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<McpOutcome<McpToolset
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- ulohy
|
||||
|
||||
/** Jak dlouho se ceka na ulohu, nez se krok vzda. Uloha na serveru bezi dal. */
|
||||
const TASK_WAIT_MS = 600_000;
|
||||
|
||||
/** Jak casto se server ptame na stav ulohy, kdyz svuj interval nerekl. */
|
||||
const TASK_POLL_MS = 2_000;
|
||||
|
||||
/** Hodnota z odpovedi .NETu. Klice pise velkymi i malymi pismeny. */
|
||||
function pick(source: unknown, ...names: string[]): unknown {
|
||||
if (source === null || typeof source !== 'object') return undefined;
|
||||
const wanted = names.map((name) => name.toLowerCase());
|
||||
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
|
||||
if (wanted.includes(key.toLowerCase())) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
return row.type === 'text' && typeof row.text === 'string';
|
||||
})
|
||||
.map((block) => String((block as Record<string, unknown>).text))
|
||||
.join('\n')
|
||||
: '';
|
||||
|
||||
const data = result === null ? null : deep(result);
|
||||
const structuredContent = result?.structuredContent;
|
||||
const structured =
|
||||
structuredContent !== null && typeof structuredContent === 'object'
|
||||
? (structuredContent as Record<string, unknown>)
|
||||
: data !== null && typeof data === 'object' && !Array.isArray(data)
|
||||
? (data as Record<string, unknown>)
|
||||
: 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<string, unknown>,
|
||||
signal: AbortSignal,
|
||||
): Promise<McpCallResult> {
|
||||
session.extend(TASK_WAIT_MS);
|
||||
|
||||
const started = (await rpc(
|
||||
session,
|
||||
'tools/call',
|
||||
{ name, arguments: args, task: { ttl: 3_600_000 } },
|
||||
signal,
|
||||
)) as Record<string, unknown> | 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<string, unknown> | 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<Record<string, unknown>>;
|
||||
structuredContent?: Record<string, unknown>;
|
||||
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<string, unknown>
|
||||
| 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.`),
|
||||
);
|
||||
}
|
||||
|
||||
+5
-3
@@ -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<string, McpDialect> = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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<Device> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<Tokens> | null;
|
||||
}
|
||||
|
||||
/** Stav podle konektoru. Dva konektory jsou dve zarizeni, nic nesdili. */
|
||||
const sessions = new Map<string, Entry>();
|
||||
|
||||
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<string, unknown>, ...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<string, unknown>,
|
||||
headers: Record<string, string>,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ ok: boolean; status: number; body: Record<string, unknown> | 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<string, unknown> | null = null;
|
||||
if (raw.trim() !== '') {
|
||||
try {
|
||||
const value: unknown = JSON.parse(raw);
|
||||
if (value !== null && typeof value === 'object') parsed = value as Record<string, unknown>;
|
||||
} 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<string, unknown> | 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<Tokens> {
|
||||
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<Attempt> {
|
||||
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<Attempt> {
|
||||
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<string> {
|
||||
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<Tokens> => {
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -754,6 +754,8 @@ function ConnectorEditor({
|
||||
const [issues, setIssues] = useState<Array<{ field: string; message: string }>>([]);
|
||||
|
||||
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({
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{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 ? (
|
||||
<div className="grid gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
|
||||
{service.credentials.map((field) => (
|
||||
{visibleCredentials.map((field) => (
|
||||
<CredentialInput
|
||||
key={field.id}
|
||||
field={field}
|
||||
|
||||
@@ -262,6 +262,8 @@ export interface ServiceCredentialField {
|
||||
/** true = hodnota se ze serveru nikdy nevraci. */
|
||||
secret: boolean;
|
||||
hint?: string;
|
||||
/** true = hodnotu spravuje portal, ve formulari se nezobrazuje. */
|
||||
managed?: boolean;
|
||||
}
|
||||
|
||||
export interface ServiceOperation {
|
||||
|
||||
Reference in New Issue
Block a user