From 92fecca70cbbe6e605d8dbb5f20e6f69ab03f5a6 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:52:23 +0200 Subject: [PATCH] MCP: prihlaseni jmenem a heslem, tokeny si obstarava portal Predchozi verze chtela po uzivateli token. Spatne zadani: 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 prace portalu. Novy src/mcp/auth.ts: - kde se prihlasit se zjisti od serveru pres /.well-known/oauth-protected-resource a metadata autorizacniho serveru. Rucni pole na adresu je jen zaloha pro servery, ktere metadata nemaji - cim se zkusi v poradi client_credentials a password. Ktere z toho firma dostala, se z udaju samych poznat neda a nutit ji to vybirat by znamenalo ptat se na neco, co nevi - bez autorizacniho serveru se posle HTTP Basic. Mensi servery zadny OAuth nemaji a jmeno s heslem je u nich presne tohle - zivotnost urcuje server: token se vymeni minutu pred vyprsenim, obnovi se pres refresh_token, kdyz ho server vydal, a kdyz expires_in chybi, pocita se s peti minutami, tedy odhaduje se dolu - odmitnuty token (401 na token, ktery jsme meli za platny) se zahodi a volani se zopakuje jednou. Podruhe uz ne, to uz nejsou platne udaje Token se drzi jen v pameti. Je kratkodoby, po restartu se o novy rekne znovu, a ulozit ho by znamenalo vsechna rizika ulozeni bez jakekoliv vyhody. Kes drzi otisk udaju, takze zmena hesla ulozeny token zneplatni. Udaje konektoru jsou ted adresa, jmeno, heslo a dve nepovinna pole (adresa pro prihlaseni a rozsah opravneni). Ani jedno nemiri do hlavicky - Authorization se pocita az pri volani z toho, co vydal autorizacni server. Zpusob prihlaseni se pise do hlasky u konektoru: uzivatel vyplnil jmeno a heslo a ma vedet, jak s nimi portal nalozil, nez zacne hledat chybu jinde. Co-Authored-By: Claude Opus 5 (1M context) --- documentation/24-mcp-konektory.md | 75 ++++-- documentation/99-zmeny.md | 29 ++ src/data/services.ts | 55 +++- src/mcp/auth.ts | 426 ++++++++++++++++++++++++++++++ src/mcp/client.ts | 101 +++++-- 5 files changed, 627 insertions(+), 59 deletions(-) create mode 100644 src/mcp/auth.ts diff --git a/documentation/24-mcp-konektory.md b/documentation/24-mcp-konektory.md index e188633..3c7737e 100644 --- a/documentation/24-mcp-konektory.md +++ b/documentation/24-mcp-konektory.md @@ -18,7 +18,7 @@ u nas. ## Jak to vypada 1. Konektory, Novy konektor, sluzba **MCP server**. -2. Vyplni se adresa serveru a token. +2. Vyplni se adresa serveru, jmeno a heslo. 3. Tlacitko **Nacist nastroje**. Portal se serveru zepta, co nabizi. 4. V builderu jsou nastroje jako kroky, s vlastnimi poli a vystupy. @@ -27,28 +27,63 @@ popis, jake parametry prijima (povinne s hvezdickou) a jake hodnoty vraci. ## Co si firma vyplni -| Pole | K cemu | -| ------------------- | -------------------------------------------------- | -| Adresa MCP serveru | cely endpoint, napr. `https://mcp.firma.cz/mcp` | -| Token | posila se jako `Authorization: Bearer` | -| API klic v hlavicce | pro servery, ktere misto tokenu chteji `X-API-Key` | +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 SMTP. -Vlastni jmeno hlavicky se zadat neda. Bud `Authorization: Bearer`, nebo -`X-API-Key` - to jsou dva zpusoby, kterymi se autorizuje drtiva vetsina -serveru. Zadat libovolnou hlavicku by znamenalo zmenu modelu pristupovych -udaju, kde jmeno hlavicky urcuje sluzba, ne konektor. +## 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: + +| 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 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. + +**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. ## 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 token sedi. Nic to nemeni, da se to spustit kdykoliv. +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: @@ -104,8 +139,11 @@ text odpovedi. Neni to nedodelek u nas. - **Adresa nesmi mirit do vnitrni site.** Tataz kontrola jako u HTTP a SMTP, vyplnuje ji firma. -- **Token neopousti server.** Do prohlizece se nevraci a v logu je zredigovany +- **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. - **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 @@ -134,6 +172,7 @@ text odpovedi. Neni to nedodelek u nas. | Cast | Soubor | | ------------------- | ------------------------------------------- | | 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` | @@ -144,12 +183,12 @@ 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 | -| Vlastni jmeno hlavicky | jen `Authorization` a `X-API-Key` | +| 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 | ### Nastroje pro model diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 66c6a46..ad35ae7 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,35 @@ Nejnovejsi nahore. +## 2026-08-28 - MCP: prihlaseni jmenem a heslem, tokeny si resi portal + +Predchozi verze chtela po uzivateli token. To bylo spatne zadani: 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. + +Novy `src/mcp/auth.ts` resi cely zivotni cyklus: + +- **Kde se prihlasit** se zjisti od serveru pres + `/.well-known/oauth-protected-resource` a metadata autorizacniho serveru. + Rucni pole na adresu je jen zaloha pro servery, ktere metadata nemaji. +- **Cim** se zkusi v poradi `client_credentials` a `password`. Ktere z toho + firma dostala, se z udaju samych poznat neda a nutit ji to vybirat by + znamenalo ptat se na neco, co nevi. +- **Bez autorizacniho serveru** se posle HTTP Basic. Mensi servery zadny OAuth + nemaji a jmeno s heslem je u nich presne tohle. +- **Zivotnost urcuje server.** Token se vymeni minutu pred vyprsenim, obnovi se + pres `refresh_token`, kdyz ho server vydal, a kdyz `expires_in` chybi, pocita + se s peti minutami - tedy odhaduje se dolu. +- **Odmitnuty token** (401 na token, ktery jsme meli za platny) se zahodi + a volani se zopakuje jednou. Podruhe uz ne, to uz nejsou platne udaje. + +Token se drzi **jen v pameti**. Je kratkodoby, po restartu se o novy rekne +znovu, a ulozit ho by znamenalo vsechna rizika ulozeni bez jakekoliv vyhody. +Kes si drzi otisk udaju, takze zmena hesla ulozeny token zneplatni. + +Zpusob prihlaseni je videt v hlasce u konektoru: uzivatel vyplnil jmeno a heslo +a ma vedet, jak s nimi portal nalozil, nez zacne hledat chybu jinde. + ## 2026-08-28 - MCP konektory hotove Firma si zalozi napojeni na svuj MCP server, stiskne **Nacist nastroje** a jeho diff --git a/src/data/services.ts b/src/data/services.ts index 0ccf9b1..1cc116d 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -2425,7 +2425,7 @@ export const services: Service[] = [ name: 'MCP server', category: 'ai', description: - 'Napojení na vlastní MCP server. Portál si od něj vyžádá seznam nástrojů a ty se pak dají použít jako kroky automatizace.', + '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.', icon: 'Plug', status: 'available', general: false, @@ -2442,25 +2442,52 @@ 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: 'token', - label: 'Token', - target: 'header', - name: 'Authorization', - // Uzivatel vlepi token tak, jak ho dostal. Slovo Bearer dopise runtime. - prefix: 'Bearer ', + id: 'username', + label: 'Jméno', + target: 'config', + name: 'username', required: false, - secret: true, - hint: 'Posílá se jako Authorization: Bearer. Vložte jen token, slovo Bearer doplní portál.', + secret: false, + hint: 'Jméno nebo ID aplikace, které jste dostali k serveru. Prázdné u serveru bez přihlášení.', }, { - id: 'apiKey', - label: 'API klíč v hlavičce X-API-Key', - target: 'header', - name: 'X-API-Key', + id: 'password', + label: 'Heslo', + target: 'config', + name: 'password', required: false, secret: true, - hint: 'Pro servery, které místo tokenu chtějí klíč v téhle hlavičce. Vyplňte jedno, nebo druhé.', + hint: 'Heslo nebo tajný klíč k tomu jménu. Portál si za ně sám vyzvedne přístup a obnovuje ho.', + }, + { + id: 'tokenUrl', + label: 'Adresa pro přihlášení', + target: 'config', + name: 'tokenUrl', + required: false, + secret: false, + hint: 'Nechte prázdné. Vyplňuje se jen tehdy, když ji portál u serveru sám nenajde.', + }, + { + id: 'scope', + label: 'Rozsah oprávnění', + target: 'config', + name: 'scope', + required: false, + secret: false, + hint: 'Nechte prázdné, pokud vám provozovatel serveru neřekl konkrétní hodnotu.', }, ], triggers: [], diff --git a/src/mcp/auth.ts b/src/mcp/auth.ts new file mode 100644 index 0000000..ffca5f9 --- /dev/null +++ b/src/mcp/auth.ts @@ -0,0 +1,426 @@ +/** + * 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. + * + * Postup je vzdy stejny a v tomhle poradi: + * + * 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. + * + * 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. + */ + +import { config } from '../config.js'; +import type { ResolvedTarget } from '../scripts/connections.js'; +import { truncate } from '../scripts/util.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. + */ +const EXPIRY_MARGIN_MS = 60_000; + +/** + * Zivotnost, kdyz ji server neuvede. + * + * `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. + */ +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'; + +export interface Authorization { + headers: Record; + /** Co se musi zredigovat v logu. Hotove hlavicky i holé hodnoty. */ + secrets: string[]; + method: AuthMethod; +} + +interface CachedToken { + 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. */ + fingerprint: string; +} + +const cache = new Map(); + +/** Chyba prihlaseni. Nese vetu pro uzivatele, ne stack. */ +export class AuthFailure extends Error { + constructor( + message: string, + readonly detail: string | null = null, + ) { + super(message); + this.name = 'AuthFailure'; + } +} + +interface Credentials { + serverUrl: string; + username: string; + password: string; + /** Vyplnena adresa pro prihlaseni. Prazdne = zjistit od serveru. */ + tokenUrl: string; + scope: string; +} + +function credentialsOf(target: ResolvedTarget): Credentials { + const value = (key: string): string => (target.serviceConfig[key] ?? '').trim(); + return { + serverUrl: value('serverUrl'), + username: value('username'), + password: value('password'), + tokenUrl: value('tokenUrl'), + scope: value('scope'), + }; +} + +/** + * Klic do kese. + * + * Konektor, ne adresa: dve firmy mohou mit tentyz server pod jinym uctem + * a token jedne nesmi obslouzit volani druhe. + */ +function cacheKey(target: ResolvedTarget, credentials: Credentials): string { + return target.connectorId ?? `${credentials.serverUrl}|${credentials.username}`; +} + +/** Otisk udaju. Zmena hesla nebo adresy musi ulozeny token zahodit. */ +function fingerprintOf(credentials: Credentials): string { + return [ + credentials.serverUrl, + 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, + ].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}`; + return { + headers: { Authorization: header }, + secrets: [encoded, header, credentials.password], + method: 'HTTP Basic', + }; +} + +/** Kratke GET na metadata. Chyba neni vyjimka, je to "nenaslo se". */ +async function readMetadata(url: string, signal: AbortSignal): Promise | 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; + const parsed: unknown = JSON.parse(raw); + return parsed !== null && typeof parsed === 'object' ? (parsed as Record) : null; + } catch { + return null; + } +} + +/** + * 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. + */ +function wellKnown(base: URL, suffix: string): string[] { + const path = base.pathname.replace(/\/+$/, ''); + const urls = [`${base.origin}/.well-known/${suffix}`]; + if (path !== '') urls.unshift(`${base.origin}/.well-known/${suffix}${path}`); + return urls; +} + +function stringField(source: Record | null, key: string): string | null { + const value = source?.[key]; + return typeof value === 'string' && value.trim() !== '' ? value.trim() : 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. + */ +async function discoverTokenUrl(serverUrl: string, signal: AbortSignal): Promise { + let base: URL; + try { + base = new URL(serverUrl); + } catch { + return null; + } + + // 1. Chraneny zdroj rekne, ktery autorizacni server za nim stoji. + 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; + 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. + let issuerUrl: URL; + try { + issuerUrl = new URL(issuer ?? base.origin); + } catch { + return null; + } + + for (const suffix of ['oauth-authorization-server', 'openid-configuration']) { + for (const url of wellKnown(issuerUrl, suffix)) { + const endpoint = stringField(await readMetadata(url, signal), 'token_endpoint'); + if (endpoint) return endpoint; + } + } + 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( + tokenUrl: string, + body: Record, + signal: AbortSignal, +): Promise { + let response: Response; + try { + response = await fetch(tokenUrl, { + method: 'POST', + signal, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + 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; + try { + parsed = JSON.parse(raw) as Record; + } 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'), + }; +} + +/** Obnoveni pres refresh token. null = nepovedlo se, jde se prihlasit znovu. */ +async function refresh( + tokenUrl: string, + refreshToken: string, + credentials: Credentials, + signal: AbortSignal, +): Promise { + return requestToken( + tokenUrl, + { + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: credentials.username, + client_secret: credentials.password, + ...(credentials.scope ? { scope: credentials.scope } : {}), + }, + signal, + ); +} + +/** + * 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, + credentials: Credentials, + signal: AbortSignal, +): Promise<{ token: TokenResponse; method: AuthMethod }> { + const scope: Record = credentials.scope ? { scope: credentials.scope } : {}; + // Indikator zdroje podle RFC 8707. Server, ktery ho nezna, ho ignoruje. + const resource: Record = { resource: credentials.serverUrl }; + + const asApplication = await requestToken( + tokenUrl, + { + grant_type: 'client_credentials', + client_id: credentials.username, + client_secret: credentials.password, + ...scope, + ...resource, + }, + 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' }; + } + + 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ě.', + ); +} + +/** + * Hlavicky pro volani MCP serveru. + * + * Uvnitr se resi cely zivotni cyklus tokenu: platny se pouzije, prosly se + * obnovi nebo vymeni za novy. Volajici o tokenu nevi. + */ +export async function authorize( + target: ResolvedTarget, + signal: AbortSignal, +): Promise { + const credentials = credentialsOf(target); + + // Server bez prihlaseni je bezny, hlavne u verejnych a vnitrofiremnich. + if (credentials.username === '' && credentials.password === '') { + return { headers: {}, secrets: [], method: 'bez přihlášení' }; + } + + const key = cacheKey(target, credentials); + const fingerprint = fingerprintOf(credentials); + const cached = cache.get(key); + + if (cached && cached.fingerprint === fingerprint) { + 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); + } + } + } + + const tokenUrl = credentials.tokenUrl || (await discoverTokenUrl(credentials.serverUrl, 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); +} + +/** + * Zahodi ulozeny token. + * + * Vola se, kdyz server odmitne token, ktery jsme povazovali za platny - treba + * proto, ze mu nekdo na druhe strane odebral opravneni driv, nez vyprsel. + * Dalsi pokus si rekne o novy. + */ +export function forgetToken(connectorId: string | null): void { + if (connectorId) cache.delete(connectorId); +} diff --git a/src/mcp/client.ts b/src/mcp/client.ts index 0a1bd63..e2859db 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -25,6 +25,7 @@ import { config } from '../config.js'; import { targetSecrets, type ResolvedTarget } from '../scripts/connections.js'; import { isPrivateHost } from '../scripts/http.js'; import { createRedactor, truncate } from '../scripts/util.js'; +import { AuthFailure, authorize, forgetToken, type AuthMethod } from './auth.js'; /** * Verze protokolu, kterou umime. @@ -170,6 +171,8 @@ interface Session { sessionId: string | null; protocolVersion: string; redact: (value: string) => string; + /** Jak se portal prihlasil. Jde to do hlasky u konektoru. */ + authMethod: AuthMethod; } let nextId = 1; @@ -273,6 +276,27 @@ async function rpc( return envelope.result; } +/** + * Sezeni pred handshakem: adresa, prihlaseni a redakce. + * + * Token si portal obstarava sam z jmena a hesla u konektoru, viz `auth.ts`. + * Vysledek prihlaseni se rovnou pridava do redakce, aby se token nedostal do + * logu ani do hlasky o chybe. + */ +async function buildSession(target: ResolvedTarget, signal: AbortSignal): Promise { + const url = serverUrl(target); + const auth = await authorize(target, signal); + + return { + url, + headers: { ...target.headers, ...auth.headers }, + sessionId: null, + protocolVersion: PROTOCOL_VERSION, + redact: createRedactor([...targetSecrets(target), ...auth.secrets]), + authMethod: auth.method, + }; +} + /** * Handshake. * @@ -280,18 +304,7 @@ async function rpc( * `notifications/initialized` - tou klient rika, ze je pripraven, a teprve * pak smi volat nastroje. */ -async function openSession( - target: ResolvedTarget, - signal: AbortSignal, -): Promise<{ session: Session; server: string }> { - const session: Session = { - url: serverUrl(target), - headers: target.headers, - sessionId: null, - protocolVersion: PROTOCOL_VERSION, - redact: createRedactor(targetSecrets(target)), - }; - +async function handshake(session: Session, signal: AbortSignal): Promise { const result = (await rpc( session, 'initialize', @@ -308,7 +321,7 @@ async function openSession( const name = result?.serverInfo?.name ?? 'neznámý server'; const version = result?.serverInfo?.version; - return { session, server: version ? `${name} ${version}` : name }; + return version ? `${name} ${version}` : name; } /** Prevede zaznam ze serveru na `McpTool`. Vraci null, kdyz to nastroj neni. */ @@ -333,11 +346,19 @@ function toTool(value: unknown): McpTool | null { }; } -/** Obal, ktery z vyjimky udela vysledek. Ven z tohoto souboru nic nevyhazuje. */ +/** + * Obal, ktery z vyjimky udela vysledek. Ven z tohoto souboru nic nevyhazuje. + * + * Zaklada sezeni vcetne prihlaseni a resi jednu vec navic: **kdyz server + * odmitne token, ktery jsme povazovali za platny, zahodi se a zkusi se to + * jeste jednou**. Stava se to, kdyz nekdo na druhe strane odebere opravneni + * driv, nez token vyprsel. Podruhe uz se to neopakuje - to uz nejsou + * pristupove udaje, ktere by sedely. + */ async function attempt( target: ResolvedTarget, - run: (signal: AbortSignal) => Promise, - onSuccess: (value: T) => string, + run: (session: Session, signal: AbortSignal) => Promise, + onSuccess: (value: T, session: Session) => string, ): Promise> { let request: McpOutcome['request'] = null; try { @@ -349,17 +370,40 @@ async function attempt( const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), config.scriptTimeoutMs); + const signal = controller.signal; + try { - const value = await run(controller.signal); - return { ok: true, message: onSuccess(value), detail: null, status: 200, request, value }; + for (let attemptNumber = 0; ; attemptNumber += 1) { + const session = await buildSession(target, signal); + try { + const value = await run(session, signal); + return { + ok: true, + message: onSuccess(value, session), + detail: null, + status: 200, + request, + value, + }; + } catch (err) { + const rejected = err instanceof McpFailure && err.status === 401; + if (rejected && attemptNumber === 0 && session.authMethod !== 'bez přihlášení') { + forgetToken(target.connectorId); + continue; + } + throw err; + } + } } catch (err) { const failure = err instanceof McpFailure ? err : null; + const auth = err instanceof AuthFailure ? err : null; + // Redakce jen z udaju konektoru: token uz je zahozeny a v hlasce byt nemuze. const redact = createRedactor(targetSecrets(target)); - const message = failure ? failure.message : err instanceof Error ? err.message : String(err); + const message = failure ?? auth ? (failure ?? auth)!.message : err instanceof Error ? err.message : String(err); return { ok: false, message: redact(message), - detail: failure?.detail ?? null, + detail: failure?.detail ?? auth?.detail ?? null, status: failure?.status ?? null, request, value: null, @@ -378,8 +422,8 @@ async function attempt( export function listTools(target: ResolvedTarget): Promise> { return attempt( target, - async (signal) => { - const { session, server } = await openSession(target, signal); + async (session, signal) => { + const server = await handshake(session, signal); const tools: McpTool[] = []; let cursor: string | undefined; @@ -408,10 +452,13 @@ export function listTools(target: ResolvedTarget): Promise - value.tools.length === 0 + // Zpusob prihlaseni patri do hlasky: uzivatel vyplnil jmeno a heslo a ma + // vedet, jak s nimi portal nalozil, nez zacne hledat chybu jinde. + (value, session) => + (value.tools.length === 0 ? `Server ${value.server} odpověděl, ale žádný nástroj nenabízí.` - : `Server ${value.server} nabízí nástrojů: ${value.tools.length}.`, + : `Server ${value.server} nabízí nástrojů: ${value.tools.length}.`) + + ` Přihlášení: ${session.authMethod}.`, ); } @@ -423,8 +470,8 @@ export function callTool( ): Promise> { return attempt( target, - async (signal) => { - const { session } = await openSession(target, signal); + async (session, signal) => { + await handshake(session, signal); const result = (await rpc(session, 'tools/call', { name, arguments: args }, signal)) as { content?: Array>; structuredContent?: Record;