Dve MCP sluzby: obecna podle specifikace a MCP EasyWeb, strankovani nastroju

MCP je standard, ale prihlaseni k nemu ne. Oficialni specifikace stoji na
OAuth 2.1 a objevovani autorizacniho serveru pres .well-known. EasyWeb
(Centaur) ma prihlaseni vlastni: POST /login s HTTP Basic vrati trojici tokenu
a obnovuje se vlastnimi endpointy. Zadny OAuth, zadne .well-known, jina verze
protokolu, zadne SSE ani hlavicka sezeni.

Proto dve sluzby, ne jedna s prepinacem: firma pri zakladani konektoru
vyplnuje neco jineho. U obecne ID a tajemstvi aplikace nebo hotovy token,
u EasyWebu jmeno, heslo a nazev zarizeni. Slucovat to by znamenalo formular,
kde je pulka poli vzdycky k nicemu, a hadani, ktera pulka to prave je.

Obecna sluzba zustava plnohodnotna. Vlastni server je duvod pridat sluzbu, ne
duvod zavrit dvere ostatnim.

Pribylo:
- src/mcp/dialect.ts - rozdily obou serveru na jednom miste: prihlaseni, verze
  protokolu, jestli se prijima SSE a jestli se posila Mcp-Session-Id. Rozesete
  po klientovi by u kazdeho dalsiho serveru pribyl dalsi if na jinem miste
- sluzba MCP EasyWeb: adresa, jmeno, heslo, nazev a otisk zarizeni.
  Prihlasovaci adresy si portal odvodi sam, otisk doplni z ID konektoru
- hotovy token u obecne sluzby. Rada verejnych serveru nic jineho nenabizi
- objevovani pres WWW-Authenticate, coz specifikace ma jako povinnou cestu.
  Pouziva se az kdyz obvykla mista selzou, stoji to volani navic
- zivotnost z tela tokenu: kdyz server expires_in ani datum neposle, cte se
  exp z JWT. Presne pripad EasyWebu
- strankovani nastroju: nastroj s parametrem cursor dostane v builderu prepinac
  Nacist vsechny stranky. Kurzor je hodnota z odpovedi, takze v dobe stavby
  stromu ho nikdo nezna a nejde ho vyplnit dopredu. Krok pak vraci navic items,
  pages, pageCount a truncated. Strop je 20 stranek

Opraveno: prihlaseni driv zkousela password grant a HTTP Basic proti hlavnimu
endpointu. Prvni OAuth 2.1 zrusil, druhe neni nikde ve specifikaci a u EasyWebu
by stejne neproslo - ten chce Basic na /login, ne na /mcp.

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