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:
co-authored by
Claude Opus 5
parent
92fecca70c
commit
81e4348ad8
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 } };
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
@@ -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 ' +
|
||||
|
||||
@@ -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
@@ -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}`];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user