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
+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