MCP EasyWeb podle skutecne specifikace: auth v2 s klicem zarizeni

Predchozi verze posilala na /login jen jmeno, heslo a nazev zarizeni. Server na
to odpovidal 400 Bad Request na cokoliv, i na spravne udaje, protoze cekal neco
uplne jineho.

EasyWeb ma auth v2: token se nevydava proti uctu, ale proti zarizeni, a to je
par klicu ECDSA P-256. Jmeno a heslo se pouziji jedinkrat, pri registraci klice,
a soucasti registrace je podpis, kterym zarizeni dokazuje, ze privatni klic
k poslanemu verejnemu opravdu ma. Od te chvile se podepisuje kazde volani, ktere
s tokeny hybe.

Overeno proti bezicimu serveru: se spravnym telem uz /login nevraci 400, ale 401
s neplatnymi udaji. Ucty z jejich testovaciho settings.json na verejnych
instancich neplati, takze dal se bez skutecnych udaju nedostanu.

Prihlaseni:
- src/mcp/easyweb/crypto.ts - klice, podpisy, otisky. Podpis musi byt P1363,
  tedy hole r||s, 64 bajtu. Node podepisuje ve vychozim nastaveni do DER a ten
  by protistrana neuznala
- src/mcp/easyweb/device.ts - klic se vyrobi jednou a prezije restart, uklada se
  mezi udaje konektoru, ktere uz jsou zasifrovane. Novy priznak `managed` na
  poli sluzby znamena, ze ho vyplnuje portal a ve formulari se nezobrazuje
- src/mcp/easyweb/session.ts - tri tokeny, retez s ustupy (platny pristupovy,
  obnova obnovovacim, obnova zarizenim, cele prihlaseni), jedno prihlaseni
  naraz na konektor, tokeny jen v pameti

Ta posledni pravidla nejsou opatrnost navic: tokeny jsou jednorazove, druhe
pouziti server odmita kodem 409 a umi zarizeni zablokovat.

Transport:
- server si sam vybira, jestli odpovi JSON telem nebo SSE streamem, a streamem
  odpovida i na obycejna volani. Klient nabizi obojí a cte stream po kouscich -
  u dlouhych uloh ho server sam nezavira
- handshake plati na token, ne na volani
- odmitnute sezeni prijde jako chyba -32008 uvnitr uspesne odpovedi
- seznamy se skladaji pres vsechny stranky, bez toho je videt jen prvni
- odpoved se rozbaluje rekurzivne (structuredContent, contents, content, JSON
  zapsany jako text)

Dlouho bezici nastroje se spousti jako uloha a ceka se na ni dotazovanim. Limit
kroku se pri tom posouva z patnacti sekund na deset minut.

Nedodelane: trvaly kanal notifikaci (GET SSE), nahravani souboru po castech
a hlidani zmen kontraktu podle verze serveru.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-28 10:58:24 +02:00
co-authored by Claude Opus 5
parent d881dab30d
commit 4d156d2837
18 changed files with 1514 additions and 404 deletions
+16
View File
@@ -224,6 +224,22 @@ export function deleteConnector(id: string, tenantIds: string[]): Promise<boolea
return repository.remove(id, tenantIds);
}
/**
* Zapise hodnoty, ktere spravuje portal, ne uzivatel.
*
* Klic zarizeni u MCP EasyWebu: vyrobi se pri prvnim prihlaseni a od te chvile
* je to identita, kterou server pozna. Ztratit ho znamena registrovat nove
* zarizeni, proto se uklada tam, kde jsou ostatni udaje konektoru, tedy
* zasifrovane.
*/
export function setManagedValues(
id: string,
values: Record<string, string>,
tenantIds: string[],
): Promise<Connector | undefined> {
return repository.setManagedValues(id, values, tenantIds);
}
/**
* Ulozi nastroje MCP serveru ke konektoru.
*
+13
View File
@@ -227,6 +227,19 @@ export function createLocalConnectors(options: LocalConnectorsOptions): Connecto
return copy(row);
},
async setManagedValues(id, values, tenantIds) {
const row = rows.find((item) => item.id === id);
if (!row || !tenantIds.includes(row.tenantId)) return undefined;
for (const [key, value] of Object.entries(values)) {
if (value === '') delete row.values[key];
else row.values[key] = value;
}
row.updatedAt = nowIso();
persist();
return copy(row);
},
async setTools(id, tools: McpToolset | null, tenantIds) {
const row = rows.find((item) => item.id === id);
if (!row || !tenantIds.includes(row.tenantId)) return undefined;
+27
View File
@@ -268,6 +268,33 @@ export const postgresConnectors: ConnectorRepository = {
return row ? toConnector(row) : undefined;
},
async setManagedValues(id, values, tenantIds) {
if (tenantIds.length === 0) return undefined;
return transaction(async (client) => {
const current = await client.query<ConnectorRow>(
`SELECT ${COLUMNS} FROM connectors WHERE id = $1 AND tenant_id = ANY($2) FOR UPDATE`,
[id, tenantIds],
);
const row = current.rows[0];
if (!row) return undefined;
const merged = openAll(row.secrets);
for (const [key, value] of Object.entries(values)) {
if (value === '') delete merged[key];
else merged[key] = value;
}
const updated = await client.query<ConnectorRow>(
`UPDATE connectors SET secrets = $2::jsonb, updated_at = now()
WHERE id = $1
RETURNING ${COLUMNS}`,
[id, JSON.stringify(sealAll(merged))],
);
return toConnector(updated.rows[0]);
});
},
async setTools(id, tools: McpToolset | null, tenantIds) {
if (tenantIds.length === 0) return undefined;
const row = await queryOne<ConnectorRow>(
+12
View File
@@ -183,6 +183,18 @@ export interface ConnectorRepository {
check: ConnectorCheck | null,
tenantIds: string[],
): Promise<Connector | undefined>;
/**
* Zapise hodnoty, ktere spravuje portal (`ServiceCredentialField.managed`).
*
* Vlastni metoda, ne `update`: zmena udaju od uzivatele znehodnocuje overeni
* konektoru, ale zapis klice zarizeni ne - ten se deje **pri** uspesnem
* prihlaseni a shodil by stav na neovereny hned po tom, co se overil.
*/
setManagedValues(
id: string,
values: Record<string, string>,
tenantIds: string[],
): Promise<Connector | undefined>;
/** Ulozi nastroje MCP serveru. Vlastni metoda, aby se nemichaly s udaji. */
setTools(
id: string,
+30 -4
View File
@@ -93,6 +93,15 @@ export interface ServiceCredentialField {
* Redakce v logu se dela na obojí, na hodnotu i na celou hlavicku.
*/
prefix?: string;
/**
* true = hodnotu **spravuje portal**, ne uzivatel.
*
* Ve formulari se nezobrazuje a nikdo ji nevyplnuje. Zapisuje ji az napojeni
* samo, kdyz si za behu neco vyrobi - klic zarizeni u MCP EasyWebu. Bez
* tohohle priznaku by uzivatel videl pole, do ktereho nema co psat, a mohl
* by ho prepsat.
*/
managed?: boolean;
}
/**
@@ -2539,7 +2548,7 @@ export const services: Service[] = [
name: 'password',
required: true,
secret: true,
hint: 'Portál si za jméno a heslo sám vyzvedne přístup a včas ho obnovuje. Token nikam nezadáváte.',
hint: 'Použije se jednou, na registraci zařízení. Dál se portál hlásí klíčem, který si vyrobí sám.',
},
{
id: 'deviceName',
@@ -2550,14 +2559,31 @@ export const services: Service[] = [
secret: false,
hint: 'Pod tímhle názvem uvidíte přihlášení v logu serveru. Prázdné znamená WorkNuke.',
},
/*
* Klic zarizeni. Vyrabi ho portal pri prvnim prihlaseni a od te chvile je
* to identita, kterou server pozna - jmeno a heslo uz se nepouziva.
*
* Je to pole konektoru, a ne zvlastni tabulka, protoze udaje konektoru se
* uz ukladaji zasifrovane a tohle je privatni klic. `managed` znamena, ze
* ho ve formulari nikdo nevidi a nevyplnuje.
*/
{
id: 'fingerprint',
id: 'deviceJwk',
label: 'Klíč zařízení',
target: 'config',
name: 'deviceJwk',
required: false,
secret: true,
managed: true,
},
{
id: 'deviceFingerprint',
label: 'Otisk zařízení',
target: 'config',
name: 'fingerprint',
name: 'deviceFingerprint',
required: false,
secret: false,
hint: 'Server podle něj pozná, že jde pořád o totéž zařízení. Prázdné doplní portál podle konektoru.',
managed: true,
},
],
triggers: [],
+49 -159
View File
@@ -10,9 +10,10 @@
* a `authorization_code` potrebuje prohlizec. Kdo od serveru dostal hotovy
* token, vyplni rovnou ten - rada verejnych serveru nic jineho nenabizi.
*
* **EasyWeb.** Zadny OAuth. `POST {server}/login` s HTTP Basic vrati trojici
* tokenu (pristupovy, obnovovaci a token zarizeni) a obnovuje se vlastnimi
* endpointy. Zakaznik dostane jmeno a heslo, token nikdy nevidi.
* **EasyWeb.** Zadny OAuth. Server vydava tokeny **zarizeni**, ne uctu: jmeno
* a heslo se pouziji jednou na registraci paru klicu a od te chvile se kazda
* obnova podepisuje. Cely postup je v `easyweb/session.ts`, tady je jen odbocka
* k nemu.
*
* Spolecne pro obojí: **token je kratkodoby, jeho zivotnost urcuje server
* a hlidat ji je prace portalu**. Drzi se jen v pameti - po restartu se o novy
@@ -24,6 +25,7 @@ import { config } from '../config.js';
import type { ResolvedTarget } from '../scripts/connections.js';
import { truncate } from '../scripts/util.js';
import { dialectFor, normalizeServerUrl } from './dialect.js';
import { ensureAccess, forgetSession, type EasyWebCredentials } from './easyweb/session.js';
/**
* O kolik driv nez vyprsi se token vymeni.
@@ -42,12 +44,15 @@ const EXPIRY_MARGIN_MS = 60_000;
*/
const DEFAULT_LIFETIME_MS = 300_000;
/** Cim se portal predstavuje serveru. Jde to do jeho logu. */
const CLIENT_INFO = 'worknuke/1.0';
/** Jak se portal prihlasil. Jde to do hlasky u konektoru. */
export type AuthMethod =
| 'bez přihlášení'
| 'vyplněný token'
| 'OAuth jako aplikace'
| 'jméno a heslo, EasyWeb';
| 'klíč zařízení, EasyWeb';
export interface Authorization {
headers: Record<string, string>;
@@ -95,9 +100,6 @@ interface Credentials {
username: string;
password: string;
deviceName: string;
fingerprint: string;
/** Nahradni otisk zarizeni, kdyz ho firma nevyplnila. */
device: string;
}
function credentialsOf(target: ResolvedTarget): Credentials {
@@ -115,8 +117,6 @@ function credentialsOf(target: ResolvedTarget): Credentials {
username: value('username'),
password: value('password'),
deviceName: value('deviceName'),
fingerprint: value('fingerprint'),
device: `worknuke-${target.connectorId ?? 'bez-konektoru'}`,
};
}
@@ -224,130 +224,6 @@ async function readJson(response: Response, where: string): Promise<Record<strin
}
}
// -------------------------------------------------------------- EasyWeb
/** Adresa vedlejsiho endpointu EasyWebu. */
function easyWebEndpoint(serverUrl: string, endpoint: string): string {
return `${serverUrl.replace(/\/+$/, '')}/${endpoint}`;
}
/**
* Prihlaseni k EasyWebu.
*
* `POST {server}/login` s HTTP Basic a telem, ktere popisuje zarizeni. Server
* vrati pristupovy, obnovovaci a zarizeni token. Nazev a otisk zarizeni si
* server pamatuje, proto je otisk vazany na konektor - kazde napojeni je pro
* nej jine zarizeni.
*/
async function easyWebLogin(credentials: Credentials, signal: AbortSignal): Promise<Session> {
const url = easyWebEndpoint(credentials.serverUrl, 'login');
const basic = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64');
let response: Response;
try {
response = await fetch(url, {
method: 'POST',
signal,
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
Name: credentials.deviceName || 'WorkNuke',
// Otisk vazany na konektor: server podle nej pozna, ze jde porad
// o totez zarizeni, a dve napojeni tehoz portalu si nesahaji do sezeni.
Fingerprint: credentials.fingerprint || credentials.device,
}),
});
} catch (err) {
throw new AuthFailure(
`Nepodařilo se spojit s ${url}: ${err instanceof Error ? err.message : String(err)}`,
);
}
if (!response.ok) {
const detail = truncate(await response.text(), config.errorDetailBytes);
/*
* 400 tady neni rozbity pozadavek u nas.
*
* Server ma `400 Bad Request` jako "malformed authorization request"
* a odpovida jim i na udaje, ktere neuzna - overeno proti bezicimu
* serveru, kde spatne heslo, prazdne heslo i neznamy ucet vraceji stejnou
* odpoved jako spravne udaje. Rozlisit se to zvenku neda, takze hlaska
* musi rict obojí, jinak clovek hleda chybu v adrese.
*/
throw new AuthFailure(
response.status === 401 || response.status === 400
? `Server přihlášení nepřijal (HTTP ${response.status} z ${url}). ` +
'Ověřte jméno a heslo a to, že účet na tomhle serveru existuje. ' +
'Tenhle server odpovídá stejným kódem i na neznámý účet, takže víc ' +
'z odpovědi poznat nejde.'
: `Přihlášení na ${url} vrátilo HTTP ${response.status}.`,
detail === '' ? null : detail,
);
}
const body = await readJson(response, url);
const accessToken = stringField(body, 'accessToken');
if (!accessToken) {
throw new AuthFailure(`Odpověď z ${url} neobsahuje přístupový token.`);
}
return {
accessToken,
expiresAt: expiryFrom(body, accessToken),
refreshToken: stringField(body, 'refreshToken'),
method: 'jméno a heslo, EasyWeb',
renewUrl: credentials.serverUrl,
fingerprint: fingerprintOf(credentials),
};
}
/**
* Obnova pristupoveho tokenu EasyWebu.
*
* `GET {server}/renew-access-token` s obnovovacim tokenem v hlavicce. Vraci
* null, kdyz to neprojde - pak se jde na plne prihlaseni, coz je stav po
* vyprseni obnovovaciho tokenu.
*/
async function easyWebRenew(
session: Session,
credentials: Credentials,
signal: AbortSignal,
): Promise<Session | null> {
if (!session.refreshToken) return null;
const url = easyWebEndpoint(session.renewUrl, 'renew-access-token');
try {
const response = await fetch(url, {
method: 'GET',
signal,
headers: { Authorization: `Bearer ${session.refreshToken}`, Accept: 'application/json' },
});
if (!response.ok) {
console.warn(`[mcp] obnova tokenu na ${url} vratila HTTP ${response.status}`);
return null;
}
const body = await readJson(response, url);
const accessToken = stringField(body, 'accessToken');
if (!accessToken) return null;
return {
...session,
accessToken,
expiresAt: expiryFrom(body, accessToken),
// Server obnovovaci token obvykle vymeni taky. Kdyz ne, plati stary.
refreshToken: stringField(body, 'refreshToken') ?? session.refreshToken,
fingerprint: fingerprintOf(credentials),
};
} catch {
// Nepovedena obnova neni chyba, jde se na plne prihlaseni.
return null;
}
}
// ---------------------------------------------------------------- OAuth
/** Kratke GET na metadata. Chyba neni vyjimka, je to "nenaslo se". */
@@ -569,27 +445,48 @@ async function oauthRenew(
export async function authorize(
target: ResolvedTarget,
signal: AbortSignal,
force = false,
): Promise<Authorization> {
const credentials = credentialsOf(target);
const dialect = dialectFor(credentials.serviceId);
if (dialect.auth === 'easyweb') {
if (!target.connectorId || !target.tenantId) {
throw new AuthFailure('Napojení na MCP EasyWebu jde použít jen přes konektor firmy.');
}
if (credentials.username === '' || credentials.password === '') {
throw new AuthFailure('Vyplňte jméno a heslo, bez nich server zařízení nezaregistruje.');
}
const easyWeb: EasyWebCredentials = {
connectorId: target.connectorId,
tenantId: target.tenantId,
serverUrl: credentials.serverUrl,
username: credentials.username,
password: credentials.password,
deviceName: credentials.deviceName || 'WorkNuke',
clientInfo: CLIENT_INFO,
};
const token = await ensureAccess(easyWeb, signal, force);
return bearer(token, 'klíč zařízení, EasyWeb');
}
/*
* Hotovy token od provozovatele serveru. Nic se nezjistuje ani neobnovuje -
* plati, dokud ho nekdo nezmeni. Rada verejnych serveru nic jineho nenabizi
* a bez teto vetve by na ne nesel zalozit konektor.
*/
if (dialect.auth === 'oauth' && credentials.token !== '') {
if (credentials.token !== '') {
return bearer(credentials.token, 'vyplněný token');
}
const hasOauth = credentials.clientId !== '' || credentials.clientSecret !== '';
const hasLogin = credentials.username !== '' || credentials.password !== '';
if (dialect.auth === 'oauth' ? !hasOauth : !hasLogin) {
if (credentials.clientId === '' && credentials.clientSecret === '') {
return { headers: {}, secrets: [], method: 'bez přihlášení' };
}
const key = cacheKey(target, credentials);
const fingerprint = fingerprintOf(credentials);
if (force) cache.delete(key);
const cached = cache.get(key);
if (cached && cached.fingerprint === fingerprint) {
@@ -597,42 +494,35 @@ export async function authorize(
return bearer(cached.accessToken, cached.method);
}
// Vyprsel. Obnova je levnejsi nez cele prihlaseni znovu.
const renewed =
dialect.auth === 'easyweb'
? await easyWebRenew(cached, credentials, signal)
: await oauthRenew(cached, credentials, signal);
const renewed = await oauthRenew(cached, credentials, signal);
if (renewed) {
cache.set(key, renewed);
return bearer(renewed.accessToken, renewed.method);
}
}
let session: Session;
if (dialect.auth === 'easyweb') {
session = await easyWebLogin(credentials, signal);
} else {
const tokenUrl =
credentials.tokenUrl || (await discoverTokenUrl(credentials.serverUrl, signal));
if (!tokenUrl) {
throw new AuthFailure(
`U serveru ${credentials.serverUrl} se nepodařilo najít, kde se přihlásit. ` +
'Vyplňte adresu pro přihlášení ručně, nebo místo ID a tajemství zadejte hotový token.',
);
}
session = await oauthLogin(tokenUrl, credentials, signal);
const tokenUrl = credentials.tokenUrl || (await discoverTokenUrl(credentials.serverUrl, signal));
if (!tokenUrl) {
throw new AuthFailure(
`U serveru ${credentials.serverUrl} se nepodařilo najít, kde se přihlásit. ` +
'Vyplňte adresu pro přihlášení ručně, nebo místo ID a tajemství zadejte hotový token.',
);
}
const session = await oauthLogin(tokenUrl, credentials, signal);
cache.set(key, session);
return bearer(session.accessToken, session.method);
}
/**
* Zahodi ulozene sezeni.
* Zahodi ulozene prihlaseni konektoru.
*
* 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.
* Vola se, kdyz uz napojeni nema co drzet - typicky pri smazani konektoru.
* Behem volani se misto toho pouziva `force`, ktery u EasyWebu zkusi levnejsi
* obnovu driv nez cele prihlaseni.
*/
export function forgetToken(connectorId: string | null): void {
if (connectorId) cache.delete(connectorId);
if (!connectorId) return;
cache.delete(connectorId);
forgetSession(connectorId);
}
+460 -145
View File
@@ -3,15 +3,15 @@
*
* MCP server vystavuje **nastroje**: kazdy ma jmeno, popis a schema toho, co
* prijima a co vraci. Klient si o ne rekne (`tools/list`) a pak je vola
* (`tools/call`). Presne o to tady jde: konektor drzi adresu a token, tenhle
* soubor s nim mluvi.
* (`tools/call`).
*
* Proc to nejde pres `ctx.http` jako zbytek sluzeb:
* - MCP vraci odpoved bud jako JSON, **nebo jako SSE stream**, a to `ctx.http`
* nerozlisuje,
* - server muze zalozit sezeni a jeho ID posila **v hlavicce odpovedi**,
* kterou `ScriptHttpResponse` nenese,
* - pred prvnim volanim je povinny handshake (`initialize`).
* - server si **sam vybira**, jestli odpovi JSON telem, nebo SSE streamem,
* a stream nemusi sam zavrit - musi se cist po kouscich a skoncit hned, jak
* prijde odpoved,
* - pred prvnim volanim je povinny handshake a plati **na token**,
* - odmitnute sezeni se pozna i podle chyby uvnitr odpovedi, ne jen podle
* HTTP kodu.
*
* Je to stejny duvod, proc ma vlastni soubor i SMTP: protokol, ktery se do
* "zavolej cestu a vrat telo" nevejde. Co jde pouzit spolecne, se pouziva -
@@ -25,27 +25,31 @@ 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';
import { AuthFailure, authorize, type AuthMethod } from './auth.js';
import { dialectFor, normalizeServerUrl, type McpDialect } from './dialect.js';
/**
* Verzi protokolu urcuje druh serveru (`dialect.ts`).
*
* Server smi v odpovedi rict jinou - pak plati jeho a posila se dal v hlavicce
* `MCP-Protocol-Version`. Vnucovat mu nasi by znamenalo, ze novejsi server
* prestane fungovat, aniz by se u nas cokoliv zmenilo.
*/
/** Kdo se predstavi serveru. Nektere servery si to pisou do logu. */
const CLIENT_INFO = { name: 'worknuke', version: '1' };
const CLIENT_INFO = { name: 'worknuke', version: '1.0' };
/**
* Strop na strankovani `tools/list`.
* Strop na strankovani seznamu.
*
* Server vraci nastroje po strankach a rika kurzor na dalsi. Rozbity server
* Server vraci polozky po strankach a rika kurzor na dalsi. Rozbity server
* muze vracet porad tentyz kurzor, takze bez stropu by se cyklilo donekonecna.
*/
const MAX_PAGES = 20;
const MAX_PAGES = 100;
/**
* Kod, kterym server rika, ze sezeni uz neplati.
*
* Prijde **uvnitr uspesne odpovedi**, ne jako HTTP kod, takze bez teto
* konstanty by to vypadalo jako chyba volani a krok by skoncil misto toho, aby
* se prihlasil znovu.
*/
const SESSION_LOST_CODE = -32008;
/** Kod, kterym server rika, ze nastroj se musi spustit jako uloha. */
const TASK_REQUIRED_CODE = -32601;
/** Schema podle JSON Schema. Tvar se prochazi az v `schema.ts`. */
export type JsonSchema = Record<string, unknown>;
@@ -79,10 +83,14 @@ export interface McpToolset {
export interface McpCallResult {
/** Textova cast odpovedi, spojena pres vsechny bloky. */
text: string;
/** Strukturovana cast. Ma ji jen nastroj, ktery deklaruje `outputSchema`. */
/** Strukturovana cast, uz rozbalena. Viz `deep`. */
structured: Record<string, unknown> | null;
/** Rozbaleny obsah, at uz je to objekt, seznam nebo text. */
data: unknown;
/** true = nastroj rekl, ze se nepovedlo. Neni to chyba spojeni. */
isError: boolean;
/** Vyplnene, kdyz se nastroj spustil jako uloha na pozadi. */
taskId: string | null;
}
/**
@@ -106,6 +114,10 @@ class McpFailure extends Error {
message: string,
readonly status: number | null = null,
readonly detail: string | null = null,
/** true = server rika, ze sezeni neplati. Ma smysl se prihlasit znovu. */
readonly sessionLost = false,
/** Kod chyby JSON-RPC, kdyz ji server poslal. */
readonly code: number | null = null,
) {
super(message);
this.name = 'McpFailure';
@@ -141,32 +153,11 @@ function serverUrl(target: ResolvedTarget): URL {
return url;
}
/**
* Odpoved MCP serveru muze prijit jako SSE stream.
*
* Tvar je `data: {...}` na radek, bloky oddelene prazdnym radkem. Bere se
* prvni blok, ktery vypada jako odpoved JSON-RPC - notifikace o prubehu,
* ktere server posila pred nim, nas nezajimaji.
*/
function parseEventStream(raw: string): unknown {
for (const line of raw.split(/\r?\n/)) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (payload === '') continue;
try {
const parsed: unknown = JSON.parse(payload);
if (parsed !== null && typeof parsed === 'object' && 'id' in parsed) return parsed;
} catch {
// Nekompletni blok neni duvod skoncit, dalsi radek muze byt v poradku.
continue;
}
}
throw new McpFailure('Server odpověděl streamem, ve kterém není odpověď JSON-RPC.');
}
interface Session {
url: URL;
headers: Record<string, string>;
/** Hodnota hlavicky `Authorization`. Pozna se podle ni zmena tokenu. */
authHeader: string;
/** ID sezeni z hlavicky odpovedi. Server ho mit nemusi. */
sessionId: string | null;
protocolVersion: string;
@@ -175,15 +166,106 @@ interface Session {
authMethod: AuthMethod;
/** Cim se dany druh serveru lisi. */
dialect: McpDialect;
connectorId: string | null;
/** Posune limit celeho volani. Pouziva se, kdyz se ceka na ulohu. */
extend: (ms: number) => void;
}
/**
* Pro ktery token uz probehl handshake.
*
* Klic je konektor, hodnota je hlavicka `Authorization`. Jina hlavicka znamena
* jiny token, tedy jine sezeni, a handshake se musi zopakovat. Servery, ktere
* si sezeni drzi u tokenu, jinak odpovi, ze relace neni inicializovana.
*/
const initialized = new Map<string, string>();
let nextId = 1;
// ------------------------------------------------------------------- SSE
/** Jedna zprava z bloku SSE. `null` = blok neni odpoved, ma se preskocit. */
function messageFromBlock(block: string): Record<string, unknown> | null {
const data = block
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('');
if (data.trim() === '') return null;
let parsed: unknown;
try {
parsed = JSON.parse(data);
} catch {
// Nekompletni blok neni duvod skoncit, dalsi muze byt v poradku.
return null;
}
if (parsed === null || typeof parsed !== 'object') return null;
const message = parsed as Record<string, unknown>;
// Notifikace o prubehu nas nezajimaji, ceka se na vysledek.
if (typeof message.method === 'string' && message.method.startsWith('notifications/')) return null;
return 'result' in message || 'error' in message ? message : null;
}
/**
* Precte SSE odpoved a skonci u prvni skutecne odpovedi.
*
* Cte se **po kouscich**, ne cele telo najednou. Server stream u dlouhych uloh
* sam nezavira a posila do nej tlukot srdce, takze cekani na konec by skoncilo
* az timeoutem.
*
* `null` znamena, ze stream skoncil, aniz by odpoved prisla. Server to obcas
* udela a pro volajiciho to neni chyba spojeni, ale prazdny vysledek.
*/
async function readEventStream(response: Response): Promise<Record<string, unknown> | null> {
const body = response.body;
if (!body) return null;
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > config.scriptMaxResponseBytes) {
throw new McpFailure(
`Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`,
response.status,
);
}
// .NET posila konce radku jako CRLF, blok se ale pozna podle dvou LF.
buffer = (buffer + decoder.decode(value, { stream: true })).replace(/\r\n/g, '\n');
for (;;) {
const end = buffer.indexOf('\n\n');
if (end === -1) break;
const block = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
const message = messageFromBlock(block);
if (message) return message;
}
}
return null;
} finally {
// Zavrit hned. Bez toho by spojeni drzelo az do timeoutu.
await reader.cancel().catch(() => undefined);
}
}
// ------------------------------------------------------------- JSON-RPC
/**
* Jedno volani JSON-RPC.
*
* `expectResult: false` je pro notifikace - na ty server neodpovida telem,
* jen kodem 202.
* `expectResult: false` je pro notifikace - na ty server neodpovida telem.
*/
async function rpc(
session: Session,
@@ -203,11 +285,11 @@ async function rpc(
method: 'POST',
signal,
headers: {
'Content-Type': 'application/json',
'Content-Type': 'application/json; charset=utf-8',
/*
* Stream se nabizi jen tam, kde ho server umi. Rict serveru, ze
* prijmeme neco, co on neposila, nevadi, ale rict to serveru, ktery si
* hlavicku kontroluje, uz vadit muze.
* Obojí. Server si sam vybira, cim odpovi, a na MCP korenu odpovida
* streamem i na obycejne volani. Nabidnout jen JSON znamena, ze cast
* serveru odmitne cely pozadavek.
*/
Accept: session.dialect.acceptEventStream
? 'application/json, text/event-stream'
@@ -235,96 +317,111 @@ async function rpc(
if (issued) session.sessionId = issued;
}
const raw = await response.text();
if (raw.length > config.scriptMaxResponseBytes) {
throw new McpFailure(
`Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`,
response.status,
);
}
const detail = session.redact(truncate(raw, config.errorDetailBytes));
const isStream = response.headers.get('content-type')?.includes('event-stream') ?? false;
if (!response.ok) {
// Telo chyby byva male, u nej se po kouscich cist nemusi.
const raw = await response.text().catch(() => '');
const detail = session.redact(truncate(raw, config.errorDetailBytes));
throw new McpFailure(
`${method} vrátilo HTTP ${response.status}.` +
(response.status === 401 || response.status === 403
? ' Server přístup odmítl, jde tedy o token nebo o oprávnění účtu, ne o adresu.'
: ''),
(response.status === 401 ? ' Server přístup odmítl.' : ''),
response.status,
detail === '' ? null : detail,
response.status === 401,
);
}
if (!expectResult) return undefined;
if (raw.trim() === '') {
throw new McpFailure(`${method} vrátilo prázdnou odpověď.`, response.status);
if (!expectResult) {
if (isStream) await readEventStream(response).catch(() => null);
else await response.text().catch(() => '');
return undefined;
}
const isStream = response.headers.get('content-type')?.includes('event-stream') ?? false;
let parsed: unknown;
let envelope: Record<string, unknown> | null;
if (isStream) {
parsed = parseEventStream(raw);
envelope = await readEventStream(response);
// Stream skoncil bez odpovedi. Server to obcas udela, neni to chyba.
if (!envelope) return null;
} else {
const raw = await response.text();
if (raw.length > config.scriptMaxResponseBytes) {
throw new McpFailure(
`Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`,
response.status,
);
}
if (raw.trim() === '') return null;
try {
parsed = JSON.parse(raw);
envelope = JSON.parse(raw) as Record<string, unknown>;
} catch {
throw new McpFailure(
`${method} nevrátilo platný JSON. Míří adresa opravdu na MCP server?`,
response.status,
detail,
session.redact(truncate(raw, config.errorDetailBytes)),
);
}
}
const envelope = parsed as { result?: unknown; error?: { code?: number; message?: string } };
if (envelope.error) {
// Chyba protokolu, ne chyba prenosu. Server napsal, co mu vadilo.
const error = envelope.error as { code?: number; message?: string } | undefined;
if (error) {
const code = typeof error.code === 'number' ? error.code : null;
throw new McpFailure(
`Server odmítl ${method}: ${envelope.error.message ?? 'bez zprávy'}`,
`Server odmítl ${method}: ${error.message ?? 'bez zprávy'}`,
response.status,
detail,
session.redact(truncate(JSON.stringify(error), config.errorDetailBytes)),
code === SESSION_LOST_CODE,
code,
);
}
return envelope.result;
return envelope.result ?? null;
}
/**
* 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> {
// --------------------------------------------------------------- sezeni
async function buildSession(
target: ResolvedTarget,
signal: AbortSignal,
force: boolean,
extend: (ms: number) => void,
): Promise<Session> {
const url = serverUrl(target);
const dialect = dialectFor(target.serviceId);
const auth = await authorize(target, signal);
const auth = await authorize(target, signal, force);
return {
url,
headers: { ...target.headers, ...auth.headers },
authHeader: auth.headers.Authorization ?? '',
sessionId: null,
protocolVersion: dialect.protocolVersion,
redact: createRedactor([...targetSecrets(target), ...auth.secrets]),
authMethod: auth.method,
dialect,
connectorId: target.connectorId,
extend,
};
}
/**
* Handshake.
* Handshake, jednou na token.
*
* Bez nej server dalsi volani odmitne. Soucasti je i notifikace
* `notifications/initialized` - tou klient rika, ze je pripraven, a teprve
* pak smi volat nastroje.
* Server drzi sezeni u tokenu, takze po jeho vymene se musi zopakovat. Delat
* ho pred kazdym volanim by byla dve volani navic pokazde.
*
* Vraci jmeno serveru, kdyz handshake probehl. Prazdny retezec znamena, ze uz
* probehl driv a jmeno tedy znovu neprislo.
*/
async function handshake(session: Session, signal: AbortSignal): Promise<string> {
async function ensureInitialized(session: Session, signal: AbortSignal): Promise<string> {
const key = session.connectorId ?? session.url.toString();
if (session.authHeader !== '' && initialized.get(key) === session.authHeader) return '';
const result = (await rpc(
session,
'initialize',
{ protocolVersion: session.dialect.protocolVersion, capabilities: {}, clientInfo: CLIENT_INFO },
signal,
)) as { protocolVersion?: string; serverInfo?: { name?: string; version?: string } };
)) as { protocolVersion?: string; serverInfo?: { name?: string; version?: string } } | null;
// Plati verze serveru. Nase je jen navrh.
if (typeof result?.protocolVersion === 'string') {
@@ -332,12 +429,59 @@ async function handshake(session: Session, signal: AbortSignal): Promise<string>
}
await rpc(session, 'notifications/initialized', undefined, signal, false);
if (session.authHeader !== '') initialized.set(key, session.authHeader);
const name = result?.serverInfo?.name ?? 'neznámý server';
const version = result?.serverInfo?.version;
return version ? `${name} ${version}` : name;
}
/** Zapomene handshake. Po odmitnutem tokenu se musi udelat znovu. */
function forgetHandshake(session: Session): void {
initialized.delete(session.connectorId ?? session.url.toString());
}
// ------------------------------------------------------------ rozbaleni
/**
* Rozbali obsah, ktery MCP vraci zabaleny na vic zpusobu.
*
* Odpoved byva `{content: [{type: "text", text: "<JSON jako retezec>"}]}`, jindy
* `{structuredContent: {...}}` a u zdroju `{contents: [...]}`. Bez rozbaleni by
* v kroku skoncil JSON zapsany jako text, se kterym uz se v podmince nic
* nesvede.
*
* `structuredContent` ma prednost, protoze to je cast, kterou sam server
* oznacil za strukturovanou.
*/
function deep(value: unknown, depth = 0): unknown {
if (depth > 6) return value;
if (typeof value === 'string') {
const text = value.trim();
if (text.startsWith('{') || text.startsWith('[')) {
try {
return deep(JSON.parse(text), depth + 1);
} catch {
return value;
}
}
return value;
}
if (value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map((item) => deep(item, depth + 1));
const record = value as Record<string, unknown>;
if (record.structuredContent !== undefined) return deep(record.structuredContent, depth + 1);
if (Array.isArray(record.contents)) return deep(record.contents, depth + 1);
if (Array.isArray(record.content)) {
const unwrapped = record.content.map((item) => deep(item, depth + 1));
return unwrapped.length === 1 ? unwrapped[0] : unwrapped;
}
if (typeof record.text === 'string') return deep(record.text, depth + 1);
return record;
}
/** Prevede zaznam ze serveru na `McpTool`. Vraci null, kdyz to nastroj neni. */
function toTool(value: unknown): McpTool | null {
if (value === null || typeof value !== 'object') return null;
@@ -360,14 +504,15 @@ function toTool(value: unknown): McpTool | null {
};
}
// ------------------------------------------------------------------ obal
/**
* 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.
* Resi jednu vec navic: **kdyz server odmitne token nebo sezeni, ktere jsme
* povazovali za platne, prihlasi se znovu a zkusi to jeste jednou**. Stava se
* to, kdyz nekdo na druhe strane odebral opravneni driv, nez token vyprsel.
* Podruhe uz se to neopakuje - to uz nejsou udaje, ktere by sedely.
*/
async function attempt<T>(
target: ResolvedTarget,
@@ -383,12 +528,21 @@ async function attempt<T>(
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.scriptTimeoutMs);
let timer = setTimeout(() => controller.abort(), config.scriptTimeoutMs);
/*
* Limit celeho volani jde posunout. Bezne volani ma na odpoved patnact
* sekund, ale nastroj spusteny jako uloha bezi minuty - a to neni zadrhel,
* to je jeho normalni chovani.
*/
const extend = (ms: number): void => {
clearTimeout(timer);
timer = setTimeout(() => controller.abort(), ms);
};
const signal = controller.signal;
try {
for (let attemptNumber = 0; ; attemptNumber += 1) {
const session = await buildSession(target, signal);
for (let round = 0; ; round += 1) {
const session = await buildSession(target, signal, round > 0, extend);
try {
const value = await run(session, signal);
return {
@@ -400,9 +554,9 @@ async function attempt<T>(
value,
};
} catch (err) {
const rejected = err instanceof McpFailure && err.status === 401;
if (rejected && attemptNumber === 0 && session.authMethod !== 'bez přihlášení') {
forgetToken(target.connectorId);
const lost = err instanceof McpFailure && err.sessionLost;
if (lost && round === 0 && session.authMethod !== 'bez přihlášení') {
forgetHandshake(session);
continue;
}
throw err;
@@ -411,9 +565,9 @@ async function attempt<T>(
} 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 ?? auth ? (failure ?? auth)!.message : err instanceof Error ? err.message : String(err);
const reported = failure ?? auth;
const message = reported ? reported.message : err instanceof Error ? err.message : String(err);
return {
ok: false,
message: redact(message),
@@ -427,47 +581,72 @@ async function attempt<T>(
}
}
// --------------------------------------------------------------- seznamy
/**
* Nacte cely seznam, i kdyz ho server posila po strankach.
*
* Bez tohohle je videt **jen prvni stranka**, coz u nastroju byva dvacet
* polozek. Vypada to jako uplny seznam, takze se na to prijde az ve chvili,
* kdy nekdo hleda nastroj, ktery na serveru je.
*/
async function stitch(
session: Session,
method: string,
key: string,
signal: AbortSignal,
): Promise<unknown[]> {
const items: unknown[] = [];
let cursor: string | undefined;
for (let page = 0; page < MAX_PAGES; page += 1) {
const result = (await rpc(session, method, cursor ? { cursor } : {}, signal)) as Record<
string,
unknown
> | null;
if (!result) break;
const batch = result[key];
if (Array.isArray(batch)) items.push(...batch);
const next = typeof result.nextCursor === 'string' ? result.nextCursor : undefined;
// Stejny kurzor podruhe by znamenal nekonecnou smycku.
if (!next || next === cursor) break;
cursor = next;
}
return items;
}
/**
* Nacte seznam nastroju serveru.
*
* Tohle je zaroven overeni konektoru: kdyz server odpovi seznamem, adresa
* i token sedí. Nic se pri tom nemeni, takze to jde spustit kdykoliv.
* i prihlaseni sedi. Nic se pri tom nemeni, takze to jde spustit kdykoliv.
*/
export function listTools(target: ResolvedTarget): Promise<McpOutcome<McpToolset>> {
return attempt(
target,
async (session, signal) => {
const server = await handshake(session, signal);
const server = await ensureInitialized(session, signal);
const rows = await stitch(session, 'tools/list', 'tools', signal);
const tools: McpTool[] = [];
let cursor: string | undefined;
for (let page = 0; page < MAX_PAGES; page += 1) {
const result = (await rpc(session, 'tools/list', cursor ? { cursor } : {}, signal)) as {
tools?: unknown[];
nextCursor?: string;
};
for (const item of result?.tools ?? []) {
const tool = toTool(item);
if (tool) tools.push(tool);
}
const next = typeof result?.nextCursor === 'string' ? result.nextCursor : undefined;
// Stejny kurzor podruhe by znamenal nekonecnou smycku.
if (!next || next === cursor) break;
cursor = next;
for (const row of rows) {
const tool = toTool(row);
if (tool) tools.push(tool);
}
const toolset: McpToolset = {
at: new Date().toISOString(),
server,
// Kdyz uz handshake probehl driv, jmeno serveru znovu neprijde.
server: server === '' ? 'server' : server,
protocolVersion: session.protocolVersion,
tools: tools.sort((a, b) => a.name.localeCompare(b.name, 'cs')),
};
return toolset;
},
// Zpusob prihlaseni patri do hlasky: uzivatel vyplnil jmeno a heslo a ma
// vedet, jak s nimi portal nalozil, nez zacne hledat chybu jinde.
// Zpusob prihlaseni patri do hlasky: uzivatel vyplnil udaje 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í.`
@@ -476,6 +655,137 @@ export function listTools(target: ResolvedTarget): Promise<McpOutcome<McpToolset
);
}
// ----------------------------------------------------------------- ulohy
/** Jak dlouho se ceka na ulohu, nez se krok vzda. Uloha na serveru bezi dal. */
const TASK_WAIT_MS = 600_000;
/** Jak casto se server ptame na stav ulohy, kdyz svuj interval nerekl. */
const TASK_POLL_MS = 2_000;
/** Hodnota z odpovedi .NETu. Klice pise velkymi i malymi pismeny. */
function pick(source: unknown, ...names: string[]): unknown {
if (source === null || typeof source !== 'object') return undefined;
const wanted = names.map((name) => name.toLowerCase());
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
if (wanted.includes(key.toLowerCase())) return value;
}
return undefined;
}
function delay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
signal.addEventListener(
'abort',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
}
/** Prevede odpoved serveru na vysledek kroku. */
function toCallResult(result: Record<string, unknown> | null, taskId: string | null): McpCallResult {
const content = result?.content;
const text = Array.isArray(content)
? content
.filter((block) => {
if (block === null || typeof block !== 'object') return false;
const row = block as Record<string, unknown>;
return row.type === 'text' && typeof row.text === 'string';
})
.map((block) => String((block as Record<string, unknown>).text))
.join('\n')
: '';
const data = result === null ? null : deep(result);
const structuredContent = result?.structuredContent;
const structured =
structuredContent !== null && typeof structuredContent === 'object'
? (structuredContent as Record<string, unknown>)
: data !== null && typeof data === 'object' && !Array.isArray(data)
? (data as Record<string, unknown>)
: null;
return { text, structured, data, isError: result?.isError === true, taskId };
}
/**
* Nastroj, ktery bezi dlouho, se spousti jako uloha.
*
* Server u nej odmitne obycejne volani a ceka, ze si o ulohu reknete zvlast.
* Na jeji konec se pak **ceka dotazovanim**, protoze stav z `tasks/list`
* a `tasks/get` je to jedine, co je vzdycky pravda - notifikace o prubehu
* chodi nejvys jednou a mohou se minout.
*
* Kdyz uloha nedobehne do naseho stropu, **neni to chyba**: na serveru bezi
* dal a krok to rekne misto toho, aby predstiral selhani.
*/
async function runTask(
session: Session,
name: string,
args: Record<string, unknown>,
signal: AbortSignal,
): Promise<McpCallResult> {
session.extend(TASK_WAIT_MS);
const started = (await rpc(
session,
'tools/call',
{ name, arguments: args, task: { ttl: 3_600_000 } },
signal,
)) as Record<string, unknown> | null;
const task = pick(started, 'task');
const taskId = pick(task, 'taskId');
if (typeof taskId !== 'string' || taskId === '') {
throw new McpFailure(`Nástroj ${name} se měl spustit jako úloha, ale server nevrátil její ID.`);
}
const interval = pick(task, 'pollInterval');
const wait = Math.max(typeof interval === 'number' ? interval : TASK_POLL_MS, 1_000);
const deadline = Date.now() + TASK_WAIT_MS;
/*
* Dokoncena uloha ze seznamu mizi, takze "neni v seznamu" neznamena "bezi".
* Po nekolika marnych kolech se prejde na primy dotaz, ktery zna i ulohy,
* ktere uz ze seznamu vypadly.
*/
let missing = 0;
for (;;) {
await delay(wait, signal);
if (signal.aborted || Date.now() > deadline) break;
const list = (await rpc(session, 'tasks/list', {}, signal)) as Record<string, unknown> | null;
const tasks = pick(list, 'tasks');
const found = Array.isArray(tasks)
? tasks.find((item) => pick(item, 'taskId') === taskId)
: undefined;
if (!found) {
missing += 1;
if (missing < 3) continue;
break;
}
missing = 0;
const status = pick(found, 'status');
if (typeof status === 'string' && status.toLowerCase() !== 'working') break;
}
// Chvile navic, aby dobehlo posledni hlaseni o prubehu.
await delay(300, signal);
const result = (await rpc(session, 'tasks/get', { taskId }, signal)) as Record<
string,
unknown
> | null;
return toCallResult(result, taskId);
}
/** Zavola jeden nastroj. `args` uz musi byt v typech, ktere schema chce. */
export function callTool(
target: ResolvedTarget,
@@ -485,28 +795,33 @@ export function callTool(
return attempt(
target,
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>;
isError?: boolean;
};
await ensureInitialized(session, signal);
const text = (result?.content ?? [])
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
.map((block) => String(block.text))
.join('\n');
const call: McpCallResult = {
text,
structured:
result?.structuredContent !== null && typeof result?.structuredContent === 'object'
? (result.structuredContent ?? null)
: null,
isError: result?.isError === true,
};
return call;
try {
const result = (await rpc(session, 'tools/call', { name, arguments: args }, signal)) as
| Record<string, unknown>
| null;
return toCallResult(result, null);
} catch (err) {
/*
* Server rika, ze tenhle nastroj se takhle spustit neda a chce ho jako
* ulohu. Neni to chyba nastaveni, je to jeho zpusob, jak rict "tohle
* bezi dlouho".
*/
const needsTask =
err instanceof McpFailure &&
err.code === TASK_REQUIRED_CODE &&
/task/i.test(`${err.message}${err.detail ?? ''}`);
if (!needsTask) throw err;
return runTask(session, name, args, signal);
}
},
(value, session) => {
const how = value.taskId ? ' jako úloha' : '';
const done = value.isError
? `Nástroj ${name} skončil chybou${how}.`
: `Nástroj ${name} doběhl${how}.`;
return `${done} Přihlášení: ${session.authMethod}.`;
},
(value) => (value.isError ? `Nástroj ${name} skončil chybou.` : `Nástroj ${name} doběhl.`),
);
}
+5 -3
View File
@@ -40,8 +40,8 @@ export interface McpDialect {
/**
* Prijme klient odpoved jako SSE stream?
*
* EasyWeb ma SSE zatim jen jako zakomentovany kod, takze mu nema smysl
* rikat, ze stream umime.
* Obojí je vzdycky lepsi nez jen JSON: server si vybira sam a nabidnout mu
* jen jednu moznost znamena, ze cast serveru pozadavek odmitne.
*/
acceptEventStream: boolean;
/**
@@ -64,7 +64,9 @@ const dialects: Record<string, McpDialect> = {
auth: 'easyweb',
// Server si po handshaku kontroluje, ze hlavicka sedi na jeho konstantu.
protocolVersion: '2025-11-25',
acceptEventStream: false,
// Server si sam vybira a streamem odpovida i na obycejna volani.
acceptEventStream: true,
// Sezeni drzi u tokenu, hlavicku nevydava.
useSessionHeader: false,
},
};
+102
View File
@@ -0,0 +1,102 @@
/**
* Kryptografie pro prihlaseni k EasyWebu.
*
* Server nevydava token proti jmenu a heslu, ale proti **zarizeni**. Zarizeni
* je pár klicu ECDSA P-256 a kazde volani, ktere s tokeny hybe, se jim podepisuje
* (proof of possession). Jmeno a heslo se pouziji jedinkrat, pri registraci
* klice.
*
* Tenhle soubor umi jen ty ctyri veci, ktere na to jsou potreba: vyrobit klic,
* ulozit a nacist ho, podepsat text a spocitat otisk. Nic z toho nevi, co je
* MCP.
*
* **Podpis musi byt v tvaru P1363**, tedy holé `r || s`, 64 bajtu. Protistrana
* ho overuje pres WebCrypto a ta jiny tvar nezna - DER, ktery Node pouziva
* jako vychozi, by neprosel a hlaska by mluvila o spatnem podpisu, ne o tvaru.
*/
import crypto, { type KeyObject } from 'node:crypto';
/** Retezec, kterym zacina podpis pri registraci klice. Dane serverem. */
export const KEY_REGISTRATION_CONTEXT = 'CELEBRUM-MCP-KEY-REGISTRATION-V1';
/** Retezec, kterym zacina podpis pri obnove tokenu. Dane serverem. */
export const POP_CONTEXT = 'CELEBRUM-MCP-POP-V1';
/** Klic zarizeni tak, jak se s nim pracuje za behu. */
export interface DeviceKey {
privateKey: KeyObject;
/** Verejny klic v DER. Posila se pri registraci jako base64url. */
spkiDer: Buffer;
/** base64url(SHA-256(spkiDer)). Soucast podpisu pri registraci. */
thumbprint: string;
}
/** Vyrobi novy par klicu. Dela se **jednou za konektor**, pak uz se jen nacita. */
export function generateDeviceKey(): { key: DeviceKey; jwk: string } {
const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
return { key: fromPrivateKey(privateKey), jwk: JSON.stringify(privateKey.export({ format: 'jwk' })) };
}
/**
* Nacte klic z ulozeneho JWK.
*
* Vraci null, kdyz je zaznam poskozeny. Novy klic se pak vyrobi a zaregistruje
* znovu - je to lepsi nez spadnout, protoze bez klice se nejde prihlasit vubec.
*/
export function deviceKeyFromJwk(jwk: string): DeviceKey | null {
try {
const privateKey = crypto.createPrivateKey({ key: JSON.parse(jwk), format: 'jwk' });
return fromPrivateKey(privateKey);
} catch {
return null;
}
}
function fromPrivateKey(privateKey: KeyObject): DeviceKey {
const spkiDer = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' });
return {
privateKey,
spkiDer,
thumbprint: base64url(crypto.createHash('sha256').update(spkiDer).digest()),
};
}
/**
* Podpis textu klicem zarizeni.
*
* `dsaEncoding: 'ieee-p1363'` neni detail. Bez nej Node podepisuje do DER
* a protistrana ceka holé `r || s`.
*/
export function sign(key: DeviceKey, text: string): string {
const signature = crypto.sign('sha256', Buffer.from(text, 'utf8'), {
key: key.privateKey,
dsaEncoding: 'ieee-p1363',
});
return base64url(signature);
}
/** Podpis nad radky spojenymi novym radkem. Poradi urcuje server. */
export function signLines(key: DeviceKey, lines: string[]): string {
return sign(key, lines.join('\n'));
}
/** base64url(SHA-256(token)). Server tim rika, ktery token se obnovuje. */
export function tokenHash(token: string): string {
return base64url(crypto.createHash('sha256').update(token, 'utf8').digest());
}
/** Nahodne bajty jako base64url. Pouziva se na nonce pri registraci. */
export function randomNonce(bytes = 32): string {
return base64url(crypto.randomBytes(bytes));
}
/** Nahodne bajty jako hex. Pouziva se na `RequestId` pri obnove. */
export function randomHex(bytes = 16): string {
return crypto.randomBytes(bytes).toString('hex');
}
/** Base64 bez odsazeni a s `-_` misto `+/`, tak jak to chce server. */
export function base64url(value: Buffer): string {
return value.toString('base64url');
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Identita zarizeni pro MCP EasyWebu.
*
* Server nevydava tokeny uctu, ale **zarizeni**. Zarizeni je pár klicu, ktery
* se jednou zaregistruje jmenem a heslem a od te chvile uz je identitou sam.
* Jmeno a heslo se pak nepouziva.
*
* Z toho plynou dve veci, ktere musi platit, jinak server ucet zablokuje:
*
* - **Klic prezije restart**, jinak by se pri kazdem startu registrovalo nove
* zarizeni. Uklada se mezi udaje konektoru, tedy zasifrovane, protoze je to
* privatni klic.
* - **Jeden konektor je jedno zarizeni.** Dve napojeni tehoz portalu na tentyz
* server maji ruzne klice a ruzne tokeny. Sdilet je znamena, ze si navzajem
* spotrebuji jednorazove tokeny.
*
* Otisk (`fingerprint`) je jmeno zarizeni v ocich serveru. Musi byt stabilni
* a **parovany prave s timhle klicem**, proto se vyrabi spolu s nim a uklada
* se vedle nej.
*/
import { getConnector, setManagedValues } from '../../data/connectorStore.js';
import { deviceKeyFromJwk, generateDeviceKey, type DeviceKey } from './crypto.js';
/** ID pole konektoru, do ktereho se uklada klic. Spravuje ho portal. */
export const DEVICE_JWK_FIELD = 'deviceJwk';
/** ID pole konektoru s otiskem zarizeni. */
export const DEVICE_FINGERPRINT_FIELD = 'deviceFingerprint';
/**
* Zacatek otisku.
*
* Referencni klient EasyWebu si tvori otisk jako `cst-agents-conn-...`. Tvar je
* jeho, ne pozadavek serveru - je to jmeno, ktere ma byt stabilni. Pouzivame
* proto svoje, aby v logu serveru bylo poznat, ze se hlasi WorkNuke, a ne
* abychom se vydavali za jejich most.
*
* Kdyby server prefix presto kontroloval, meni se to tady.
*/
const FINGERPRINT_PREFIX = 'worknuke-conn';
export interface Device {
key: DeviceKey;
fingerprint: string;
}
/**
* Klic zarizeni pro konektor. Pri prvnim volani ho vyrobi a ulozi.
*
* Zapis jde pres `setManagedValues`, ne pres bezny zapis udaju: ten
* znehodnocuje overeni konektoru, kdezto tohle se deje **pri** uspesnem
* prihlaseni a shodilo by stav na neovereny hned potom, co se overil.
*/
export async function deviceFor(connectorId: string, tenantId: string): Promise<Device> {
const connector = await getConnector(connectorId, [tenantId]);
if (!connector) throw new Error(`Konektor ${connectorId} v této firmě není.`);
const storedJwk = (connector.values[DEVICE_JWK_FIELD] ?? '').trim();
const storedFingerprint = (connector.values[DEVICE_FINGERPRINT_FIELD] ?? '').trim();
if (storedJwk !== '' && storedFingerprint !== '') {
const key = deviceKeyFromJwk(storedJwk);
// Poskozeny zaznam neni duvod spadnout - vyrobi se novy klic. Server pak
// uvidi nove zarizeni, coz je porad lepsi nez napojeni, ktere nejde pouzit.
if (key) return { key, fingerprint: storedFingerprint };
console.warn(`[mcp] ${connectorId}: ulozeny klic zarizeni je poskozeny, delam novy`);
}
const { key, jwk } = generateDeviceKey();
/*
* Nahodny konec otisku je tam kvuli tomu, aby nove zarizeni po ztrate klice
* nemelo tentyz otisk jako to stare. Server by jinak videl znamy otisk
* s jinym klicem, a to je presne obraz pokusu o podvrzeni.
*/
const suffix = key.thumbprint.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8).toLowerCase();
const fingerprint = `${FINGERPRINT_PREFIX}-${connectorId}-${suffix}`;
await setManagedValues(
connectorId,
{ [DEVICE_JWK_FIELD]: jwk, [DEVICE_FINGERPRINT_FIELD]: fingerprint },
[tenantId],
);
console.info(`[mcp] ${connectorId}: vyrobeno nove zarizeni ${fingerprint}`);
return { key, fingerprint };
}
+430
View File
@@ -0,0 +1,430 @@
/**
* Prihlaseni k MCP EasyWebu (auth v2).
*
* Server vydava tri tokeny a kazdy ma jinou roli:
*
* | Token | K cemu | Zivotnost |
* | -------- | ----------------------------------------------- | --------- |
* | pristupovy | posila se s kazdym volanim MCP | ~30 minut |
* | obnovovaci | vymeni pristupovy za novy | delsi |
* | zarizeni | vymeni obnovovaci, kdyz uz neplati | nejdelsi |
*
* Pravidla, ktera se nesmi porusit, protoze **server umi zablokovat zarizeni**:
*
* - **Tokeny jsou jednorazove.** Po obnove je stary spotrebovany a druhe
* pouziti server odmitne kodem 409. Proto se drzi vzdy jen ten nejnovejsi
* a vymena je atomicka.
* - **Jedno prihlaseni naraz.** Dve soubezna volani nad tim samym konektorem
* nesmi spustit dve obnovy - druha by pracovala se spotrebovanym tokenem.
* Resi to jedna sdilena rozdelana operace na konektor.
* - **Tokeny nikam nezapisovat.** Po restartu je stary token uz neplatny
* a jeho pouziti vypada jako pokus o zneuziti. Prezije jen klic zarizeni.
*
* Kazde volani, ktere s tokeny hybe, se podepisuje klicem zarizeni. Jmeno
* a heslo se pouziji **jen jednou**, pri registraci klice.
*/
import { config } from '../../config.js';
import { truncate } from '../../scripts/util.js';
import {
KEY_REGISTRATION_CONTEXT,
POP_CONTEXT,
base64url,
randomHex,
randomNonce,
signLines,
tokenHash,
} from './crypto.js';
import { deviceFor, type Device } from './device.js';
/**
* Rezerva pred vyprsenim pristupoveho tokenu.
*
* Referencni klient obnovuje pri zbylych 90 sekundach. Kratsi rezerva znamena,
* ze token muze vyprset po ceste, delsi jen obnovuje casteji.
*/
const RENEW_MARGIN_MS = 90_000;
/** Jak dlouho token plati, kdyz to z nej nejde vycíst. Odhad smerem dolu. */
const FALLBACK_LIFETIME_MS = 300_000;
/** Chyba prihlaseni. Vetu cte uzivatel, proto zadny stack. */
export class EasyWebAuthError extends Error {
constructor(
message: string,
readonly status: number | null = null,
readonly detail: string | null = null,
) {
super(message);
this.name = 'EasyWebAuthError';
}
}
export interface EasyWebCredentials {
connectorId: string;
tenantId: string;
/** Adresa MCP serveru, tedy `mcpRoot`. */
serverUrl: string;
username: string;
password: string;
/** Nazev, pod kterym je zarizeni videt v logu serveru. */
deviceName: string;
clientInfo: string;
}
interface Tokens {
accessToken: string;
accessExpiresAt: number;
refreshToken: string;
refreshNonce: string;
deviceToken: string;
deviceNonce: string;
}
interface Entry {
tokens: Tokens | null;
/** Rozdelane prihlaseni. Druhy volajici pocka, misto aby zacal svoje. */
inflight: Promise<Tokens> | null;
}
/** Stav podle konektoru. Dva konektory jsou dve zarizeni, nic nesdili. */
const sessions = new Map<string, Entry>();
function entryFor(connectorId: string): Entry {
let entry = sessions.get(connectorId);
if (!entry) {
entry = { tokens: null, inflight: null };
sessions.set(connectorId, entry);
}
return entry;
}
// ------------------------------------------------------------------ volani
/** Odpoved serveru jako objekt. Klice cte necitlive, .NET je pise velkymi. */
function fieldOf(body: Record<string, unknown>, ...names: string[]): string | null {
const wanted = names.map((name) => name.toLowerCase());
for (const [key, value] of Object.entries(body)) {
if (!wanted.includes(key.toLowerCase())) continue;
if (typeof value === 'string' && value.trim() !== '') return value.trim();
}
return null;
}
/**
* Kdy vyprsi pristupovy token.
*
* Cte se `exp` z tela JWT. Server zivotnost jinak nikam nepise, takze bez
* tohohle by se token drzel do prvniho odmitnuti - a to uz je zbytecne volani
* navic u kazdeho kroku.
*/
function accessExpiry(token: string): number {
const parts = token.split('.');
if (parts.length >= 2) {
try {
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as {
exp?: unknown;
};
if (typeof payload.exp === 'number') return payload.exp * 1000;
} catch {
// Neni to JWT. Plati odhad nize.
}
}
return Date.now() + FALLBACK_LIFETIME_MS;
}
function endpoint(serverUrl: string, path: string): string {
return `${serverUrl.replace(/\/+$/, '')}/${path}`;
}
/**
* Jedno volani na prihlasovaci endpoint.
*
* 401 a 409 se **nevyhazuji jako koncova chyba**: obojí znamena "tenhle token
* uz neplati" a nasleduje dalsi clanek retezu. 409 je navic to, cim server
* rika, ze token uz nekdo spotreboval.
*/
async function post(
url: string,
body: Record<string, unknown>,
headers: Record<string, string>,
signal: AbortSignal,
): Promise<{ ok: boolean; status: number; body: Record<string, unknown> | null; detail: string }> {
let response: Response;
try {
response = await fetch(url, {
method: 'POST',
signal,
headers: { 'Content-Type': 'application/json; charset=utf-8', Accept: 'application/json', ...headers },
body: JSON.stringify(body),
});
} catch (err) {
const name = err instanceof Error ? err.name : '';
if (name === 'AbortError' || name === 'TimeoutError') {
throw new EasyWebAuthError(`Server ${url} neodpověděl v limitu.`);
}
throw new EasyWebAuthError(
`Nepodařilo se spojit s ${url}: ${err instanceof Error ? err.message : String(err)}`,
);
}
const raw = await response.text();
const detail = truncate(raw, config.errorDetailBytes);
let parsed: Record<string, unknown> | null = null;
if (raw.trim() !== '') {
try {
const value: unknown = JSON.parse(raw);
if (value !== null && typeof value === 'object') parsed = value as Record<string, unknown>;
} catch {
parsed = null;
}
}
return { ok: response.ok, status: response.status, body: parsed, detail };
}
/** Slozi tokeny z odpovedi. Chybejici pristupovy token je chyba. */
function tokensFrom(
body: Record<string, unknown> | null,
previous: Tokens | null,
where: string,
): Tokens {
const accessToken = body ? fieldOf(body, 'AccessToken') : null;
if (!accessToken) {
throw new EasyWebAuthError(`Odpověď z ${where} neobsahuje přístupový token.`);
}
/*
* Co odpoved neposlala, plati z minula. `renew-access` vraci jen pristupovy
* a obnovovaci token, token zarizeni zustava - prepsat ho prazdnou hodnotou
* by znamenalo, ze pri dalsi obnove neni cim.
*/
return {
accessToken,
accessExpiresAt: accessExpiry(accessToken),
refreshToken: (body ? fieldOf(body, 'RefreshToken') : null) ?? previous?.refreshToken ?? '',
refreshNonce: (body ? fieldOf(body, 'RefreshTokenNonce') : null) ?? previous?.refreshNonce ?? '',
deviceToken: (body ? fieldOf(body, 'DeviceToken') : null) ?? previous?.deviceToken ?? '',
deviceNonce: (body ? fieldOf(body, 'DeviceTokenNonce') : null) ?? previous?.deviceNonce ?? '',
};
}
// ------------------------------------------------------- clanky retezu
/**
* Registrace zarizeni jmenem a heslem.
*
* Jedine volani, ktere pouziva jmeno a heslo. Soucasti je podpis, kterym
* zarizeni dokazuje, ze privatni klic k poslanemu verejnemu opravdu ma.
*/
async function login(
credentials: EasyWebCredentials,
device: Device,
signal: AbortSignal,
): Promise<Tokens> {
const url = endpoint(credentials.serverUrl, 'login');
const nonce = randomNonce();
const proof = signLines(device.key, [
KEY_REGISTRATION_CONTEXT,
credentials.username.trim(),
device.fingerprint,
device.key.thumbprint,
nonce,
]);
const basic = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64');
const result = await post(
url,
{
Name: credentials.deviceName,
Fingerprint: device.fingerprint,
ClientInfo: credentials.clientInfo,
PublicKey: base64url(device.key.spkiDer),
PublicKeyAlgorithm: 'ES256',
KeyProofNonce: nonce,
KeyProof: proof,
},
{ Authorization: `Basic ${basic}` },
signal,
);
if (!result.ok) {
throw new EasyWebAuthError(
result.status === 401 || result.status === 400
? 'Server přihlášení nepřijal. Ověřte jméno a heslo a to, že účet na tomhle ' +
'serveru existuje a smí zakládat zařízení.'
: `Přihlášení na ${url} vrátilo HTTP ${result.status}.`,
result.status,
result.detail === '' ? null : result.detail,
);
}
return tokensFrom(result.body, null, url);
}
/** Vysledek clanku retezu. `null` = tenhle zpusob neprosel, zkusi se dalsi. */
type Attempt = Tokens | null;
/**
* Obnova pristupoveho tokenu obnovovacim.
*
* **Obnovovaci token pri tom rotuje** - odpoved nese novy a stary uz neplati.
* Ulozit se musi oba, jinak dalsi obnova narazi na spotrebovany token.
*/
async function renewAccess(
credentials: EasyWebCredentials,
device: Device,
tokens: Tokens,
signal: AbortSignal,
): Promise<Attempt> {
if (tokens.refreshToken === '') return null;
const url = endpoint(credentials.serverUrl, 'renew-access-token');
const requestId = randomHex();
const proof = signLines(device.key, [
POP_CONTEXT,
'renew-access',
tokenHash(tokens.refreshToken),
tokens.refreshNonce,
requestId,
]);
const result = await post(
url,
{
RefreshToken: tokens.refreshToken,
Nonce: tokens.refreshNonce,
RequestId: requestId,
DeviceProof: proof,
},
{},
signal,
);
// 401 i 409 znamenaji "tenhle token uz neplati", pokracuje se dal retezem.
if (result.status === 401 || result.status === 409) return null;
if (!result.ok) {
throw new EasyWebAuthError(
`Obnova přístupu na ${url} vrátila HTTP ${result.status}.`,
result.status,
result.detail === '' ? null : result.detail,
);
}
return tokensFrom(result.body, tokens, url);
}
/** Obnova obnovovaciho tokenu tokenem zarizeni. Vraci celou sadu. */
async function renewRefresh(
credentials: EasyWebCredentials,
device: Device,
tokens: Tokens,
signal: AbortSignal,
): Promise<Attempt> {
if (tokens.deviceToken === '') return null;
const url = endpoint(credentials.serverUrl, 'renew-refresh-token');
const requestId = randomHex();
const proof = signLines(device.key, [
POP_CONTEXT,
'renew-refresh',
tokenHash(tokens.deviceToken),
tokens.deviceNonce,
requestId,
]);
const result = await post(
url,
{
DeviceToken: tokens.deviceToken,
Nonce: tokens.deviceNonce,
RequestId: requestId,
DeviceProof: proof,
},
{},
signal,
);
if (result.status === 401 || result.status === 409) return null;
if (!result.ok) {
throw new EasyWebAuthError(
`Obnova zařízení na ${url} vrátila HTTP ${result.status}.`,
result.status,
result.detail === '' ? null : result.detail,
);
}
return tokensFrom(result.body, tokens, url);
}
// ------------------------------------------------------------------- ven
/**
* Platny pristupovy token.
*
* Retez s ustupy: platny token, obnova obnovovacim, obnova zarizenim, cele
* prihlaseni. Kazdy dalsi clanek je drazsi, proto se jde odshora.
*
* `force` zahodi i token, ktery jeste plati. Pouziva se po tom, co ho server
* odmitl - to znamena, ze uz neplati, at si o nem myslime cokoliv.
*/
export async function ensureAccess(
credentials: EasyWebCredentials,
signal: AbortSignal,
force = false,
): Promise<string> {
const entry = entryFor(credentials.connectorId);
if (!force && entry.tokens && Date.now() < entry.tokens.accessExpiresAt - RENEW_MARGIN_MS) {
return entry.tokens.accessToken;
}
/*
* Jedno prihlaseni naraz. Bez tohohle by dve soubezne automatizace nad tim
* samym konektorem spustily dve obnovy, druha by pracovala se spotrebovanym
* tokenem a server by zarizeni zablokoval.
*/
if (entry.inflight) {
const tokens = await entry.inflight;
if (!force) return tokens.accessToken;
}
const run = (async (): Promise<Tokens> => {
const device = await deviceFor(credentials.connectorId, credentials.tenantId);
const current = entry.tokens;
if (current) {
const renewed = await renewAccess(credentials, device, current, signal);
if (renewed) return renewed;
const restored = await renewRefresh(credentials, device, current, signal);
if (restored) return restored;
console.warn(
`[mcp] ${credentials.connectorId}: obnovovaci token ani token zarizeni neprosly, hlasim se znovu`,
);
}
return login(credentials, device, signal);
})();
entry.inflight = run;
try {
const tokens = await run;
// Vymena je atomicka: bud plati cela nova sada, nebo zustava stara.
entry.tokens = tokens;
return tokens.accessToken;
} finally {
if (entry.inflight === run) entry.inflight = null;
}
}
/**
* Zahodi tokeny konektoru.
*
* Vola se, kdyz server odmitne token, ktery jsme povazovali za platny.
* Zarizeni zustava, prihlasi se znovu jeho klicem.
*/
export function forgetSession(connectorId: string): void {
const entry = sessions.get(connectorId);
if (!entry) return;
entry.tokens = null;
}
+10
View File
@@ -19,6 +19,14 @@ import { findService, type Service } from '../data/services.js';
export interface ResolvedTarget {
serviceId: string;
connectorId: string | null;
/**
* Firma, ktere konektor patri. `null` u obecnych sluzeb bez napojeni.
*
* Je tu proto, ze nektera napojeni si za behu **zapisuji vlastni udaje**
* zpatky ke konektoru - klic zarizeni u MCP EasyWebu. Zapis do uloziste
* potrebuje filtr na firmu a ten se nesmi dohadovat.
*/
tenantId: string | null;
/** Prazdne u obecnych sluzeb - `ctx.http` pak vyzaduje absolutni adresu. */
baseUrl: string;
/** Vcetne tajemstvi. Nikdy neposilat na klienta ani do logu. */
@@ -85,6 +93,7 @@ export function resolveTarget(serviceId: string, connector: Connector | null): R
return {
serviceId,
connectorId: connector?.id ?? null,
tenantId: connector?.tenantId ?? null,
baseUrl: '',
headers: {},
secretValues: [],
@@ -131,6 +140,7 @@ export function resolveTarget(serviceId: string, connector: Connector | null): R
return {
serviceId,
connectorId: connector?.id ?? null,
tenantId: connector?.tenantId ?? null,
baseUrl: connector?.baseUrl ?? serviceBaseUrl(service),
headers,
secretValues,