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:
JiriUhlir
2026-08-28 10:58:24 +02:00
co-authored by Claude Opus 5
parent d881dab30d
commit 4d156d2837
18 changed files with 1514 additions and 404 deletions
+4
View File
@@ -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.
+1 -1
View File
@@ -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 |
+193 -90
View File
@@ -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.
+65
View File
@@ -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
+16
View File
@@ -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.
*
+13
View File
@@ -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;
+27
View File
@@ -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>(
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
};
+102
View File
@@ -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');
}
+87
View File
@@ -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 };
}
+430
View File
@@ -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;
}
+10
View File
@@ -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,
+8 -2
View File
@@ -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}
+2
View File
@@ -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 {