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:
JiriUhlir
2026-08-28 09:52:23 +02:00
co-authored by Claude Opus 5
parent 435e254c90
commit 92fecca70c
5 changed files with 627 additions and 59 deletions
+41 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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>;