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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
435e254c90
commit
92fecca70c
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+41
-14
@@ -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: [],
|
||||
|
||||
+426
@@ -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<string, string>;
|
||||
/** 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<string, CachedToken>();
|
||||
|
||||
/** 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<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;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return parsed !== null && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : 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<string, unknown> | 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<string | null> {
|
||||
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<string, string>,
|
||||
signal: AbortSignal,
|
||||
): Promise<TokenResponse | null> {
|
||||
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<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'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Obnoveni pres refresh token. null = nepovedlo se, jde se prihlasit znovu. */
|
||||
async function refresh(
|
||||
tokenUrl: string,
|
||||
refreshToken: string,
|
||||
credentials: Credentials,
|
||||
signal: AbortSignal,
|
||||
): Promise<TokenResponse | null> {
|
||||
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<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 };
|
||||
|
||||
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<Authorization> {
|
||||
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);
|
||||
}
|
||||
+74
-27
@@ -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<Session> {
|
||||
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<string> {
|
||||
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<T>(
|
||||
target: ResolvedTarget,
|
||||
run: (signal: AbortSignal) => Promise<T>,
|
||||
onSuccess: (value: T) => string,
|
||||
run: (session: Session, signal: AbortSignal) => Promise<T>,
|
||||
onSuccess: (value: T, session: Session) => string,
|
||||
): Promise<McpOutcome<T>> {
|
||||
let request: McpOutcome<T>['request'] = null;
|
||||
try {
|
||||
@@ -349,17 +370,40 @@ async function attempt<T>(
|
||||
|
||||
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<T>(
|
||||
export function listTools(target: ResolvedTarget): Promise<McpOutcome<McpToolset>> {
|
||||
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<McpOutcome<McpToolset
|
||||
};
|
||||
return toolset;
|
||||
},
|
||||
(value) =>
|
||||
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<McpOutcome<McpCallResult>> {
|
||||
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<Record<string, unknown>>;
|
||||
structuredContent?: Record<string, unknown>;
|
||||
|
||||
Reference in New Issue
Block a user