Dve MCP sluzby: obecna podle specifikace a MCP EasyWeb, strankovani nastroju

MCP je standard, ale prihlaseni k nemu ne. Oficialni specifikace stoji na
OAuth 2.1 a objevovani autorizacniho serveru pres .well-known. EasyWeb
(Centaur) ma prihlaseni vlastni: POST /login s HTTP Basic vrati trojici tokenu
a obnovuje se vlastnimi endpointy. Zadny OAuth, zadne .well-known, jina verze
protokolu, zadne SSE ani hlavicka sezeni.

Proto dve sluzby, ne jedna s prepinacem: firma pri zakladani konektoru
vyplnuje neco jineho. U obecne ID a tajemstvi aplikace nebo hotovy token,
u EasyWebu jmeno, heslo a nazev zarizeni. Slucovat to by znamenalo formular,
kde je pulka poli vzdycky k nicemu, a hadani, ktera pulka to prave je.

Obecna sluzba zustava plnohodnotna. Vlastni server je duvod pridat sluzbu, ne
duvod zavrit dvere ostatnim.

Pribylo:
- src/mcp/dialect.ts - rozdily obou serveru na jednom miste: prihlaseni, verze
  protokolu, jestli se prijima SSE a jestli se posila Mcp-Session-Id. Rozesete
  po klientovi by u kazdeho dalsiho serveru pribyl dalsi if na jinem miste
- sluzba MCP EasyWeb: adresa, jmeno, heslo, nazev a otisk zarizeni.
  Prihlasovaci adresy si portal odvodi sam, otisk doplni z ID konektoru
- hotovy token u obecne sluzby. Rada verejnych serveru nic jineho nenabizi
- objevovani pres WWW-Authenticate, coz specifikace ma jako povinnou cestu.
  Pouziva se az kdyz obvykla mista selzou, stoji to volani navic
- zivotnost z tela tokenu: kdyz server expires_in ani datum neposle, cte se
  exp z JWT. Presne pripad EasyWebu
- strankovani nastroju: nastroj s parametrem cursor dostane v builderu prepinac
  Nacist vsechny stranky. Kurzor je hodnota z odpovedi, takze v dobe stavby
  stromu ho nikdo nezna a nejde ho vyplnit dopredu. Krok pak vraci navic items,
  pages, pageCount a truncated. Strop je 20 stranek

Opraveno: prihlaseni driv zkousela password grant a HTTP Basic proti hlavnimu
endpointu. Prvni OAuth 2.1 zrusil, druhe neni nikde ve specifikaci a u EasyWebu
by stejne neproslo - ten chce Basic na /login, ne na /mcp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-28 10:13:33 +02:00
co-authored by Claude Opus 5
parent 92fecca70c
commit 81e4348ad8
14 changed files with 1006 additions and 352 deletions
+65 -8
View File
@@ -18,9 +18,10 @@
import type { Connector } from './connectorStore.js';
import { listConnectors } from './connectorStore.js';
import { MCP_SERVICE_ID, setMcpOperations, type ServiceOperation } from './services.js';
import { setMcpOperations, type ServiceOperation } from './services.js';
import { MCP_SERVICE_IDS } from '../mcp/dialect.js';
import { listTenants } from './tenants.js';
import { fieldsFromSchema, outputsFromTool } from '../mcp/schema.js';
import { cursorFieldOf, fieldsFromSchema, outputsFromTool } from '../mcp/schema.js';
import type { McpTool } from '../mcp/client.js';
/**
@@ -44,10 +45,29 @@ export function parseOperationId(id: string): { connectorId: string; toolName: s
return { connectorId: rest.slice(0, separator), toolName: rest.slice(separator + 1) };
}
/**
* ID prepinace strankovani.
*
* Nezacina jako parametr nastroje, je nas - proto podtrzitko na zacatku.
* Server zadne pole s tim jmenem mit nemuze, protoze do argumentu se davaji
* jen vlastnosti z jeho schematu.
*/
export const ALL_PAGES_INPUT = '_allPages';
/**
* Strop na pocet stranek jednoho kroku.
*
* Rozbity server muze vracet porad tentyz kurzor. Bez stropu by krok bezel,
* dokud ho nezastavi timeout, a mezitim by volal cizi sluzbu donekonecna.
*/
export const MAX_TOOL_PAGES = 20;
interface Entry {
connectorId: string;
connectorName: string;
tenantId: string;
/** Ktera ze sluzeb MCP to je. Nastroje se do katalogu radi pod ni. */
serviceId: string;
tools: McpTool[];
}
@@ -63,12 +83,43 @@ const byConnector = new Map<string, Entry>();
*/
function toOperation(entry: Entry, tool: McpTool): ServiceOperation {
const label = tool.title ?? tool.name;
const inputs = fieldsFromSchema(tool.inputSchema);
const outputs = outputsFromTool(tool);
/*
* Nastroj, ktery umi strankovat, dostane prepinac navic.
*
* Bez nej by krok vratil prvni stranku a zbytek by uzivatel nemel jak
* dostat - kurzor je hodnota z odpovedi, kterou v dobe stavby stromu nikdo
* nezna, takze ho neslo vyplnit dopredu.
*/
if (cursorFieldOf(tool.inputSchema)) {
inputs.push({
id: ALL_PAGES_INPUT,
label: 'Načíst všechny stránky',
kind: 'choice',
required: false,
options: [
{ value: '', label: 'Ne, jen první stránku' },
{ value: 'true', label: 'Ano, projít všechny' },
],
hint:
'Nástroj vrací data po stránkách. Zapnuté je projde za sebou a výsledky spojí. ' +
`Nejvýš ${MAX_TOOL_PAGES} stránek, pak se krok zastaví a řekne to.`,
});
outputs.push(
{ id: 'items', name: 'Spojené položky', type: 'list', required: false },
{ id: 'pages', name: 'Jednotlivé stránky', type: 'list', required: false },
{ id: 'pageCount', name: 'Počet načtených stránek', type: 'number', required: true },
);
}
return {
id: operationId(entry.connectorId, tool.name),
name: `${entry.connectorName}: ${label}`,
description: tool.description || `Nástroj ${tool.name} na serveru ${entry.connectorName}.`,
inputs: fieldsFromSchema(tool.inputSchema),
outputFields: outputsFromTool(tool),
inputs,
outputFields: outputs,
// Vykonna cast neni skript, ale vnitrni krok - vsechny nastroje obsluhuje
// jeden. Priznak `implementation` je jen pro skripty, proto tu neni.
};
@@ -76,10 +127,14 @@ function toOperation(entry: Entry, tool: McpTool): ServiceOperation {
/** Prepocita, co se posila do katalogu. Vola se po kazde zmene mapy. */
function publish(): void {
const items: Array<{ tenantId: string; operation: ServiceOperation }> = [];
const items: Array<{ tenantId: string; serviceId: string; operation: ServiceOperation }> = [];
for (const entry of byConnector.values()) {
for (const tool of entry.tools) {
items.push({ tenantId: entry.tenantId, operation: toOperation(entry, tool) });
items.push({
tenantId: entry.tenantId,
serviceId: entry.serviceId,
operation: toOperation(entry, tool),
});
}
}
setMcpOperations(items);
@@ -95,6 +150,7 @@ export function rememberMcpTools(connector: Connector): void {
connectorId: connector.id,
connectorName: connector.name,
tenantId: connector.tenantId,
serviceId: connector.serviceId,
tools,
});
}
@@ -127,8 +183,8 @@ export async function refreshMcpTools(): Promise<void> {
const tenantIds = listTenants().map((tenant) => tenant.id);
byConnector.clear();
if (tenantIds.length > 0) {
const connectors = await listConnectors(tenantIds, { serviceId: MCP_SERVICE_ID });
for (const serviceId of tenantIds.length > 0 ? MCP_SERVICE_IDS : []) {
const connectors = await listConnectors(tenantIds, { serviceId });
for (const connector of connectors) {
const tools = connector.mcp?.tools ?? [];
if (tools.length === 0) continue;
@@ -136,6 +192,7 @@ export async function refreshMcpTools(): Promise<void> {
connectorId: connector.id,
connectorName: connector.name,
tenantId: connector.tenantId,
serviceId: connector.serviceId,
tools,
});
}
+120 -48
View File
@@ -20,6 +20,7 @@
import type { FieldType } from './conditions.js';
import type { User } from '../types.js';
import { isMcpService, MCP_EASYWEB_SERVICE_ID, MCP_SERVICE_ID } from '../mcp/dialect.js';
export type ServiceCategory =
/** Obecne veci, ktere ma kazdy. Nepotrebuji konektor. */
@@ -288,13 +289,6 @@ export const serviceCategories: Array<{ id: ServiceCategory; label: string }> =
{ id: 'transformace', label: 'Transformace dat' },
];
/**
* ID sluzby, pod kterou visi nastroje vsech MCP serveru.
*
* Nahore, protoze na nej odkazuje uz samotny katalog nize.
*/
export const MCP_SERVICE_ID = 'mcp';
export const services: Service[] = [
// ------------------------------------------------- obecne: spoustece
{
@@ -2408,24 +2402,26 @@ export const services: Service[] = [
},
/**
* MCP server firmy.
* MCP server podle oficialni specifikace.
*
* Jedina sluzba v katalogu, ktera **nema zadne pevne operace**. Co umi, rekne
* az server: konektor se zalozi, stiskne se Nacist nastroje a teprve tim
* vzniknou kroky, ktere jde davat do automatizaci. Doplnuje je
* `src/data/mcpTools.ts`.
* Jedina sluzba v katalogu, ktera **nema zadne pevne operace** - rekne je az
* server. Doplnuje je `src/data/mcpTools.ts`.
*
* Prihlaseni ma tri podoby a firma vyplni tu, kterou ji provozovatel serveru
* dal. Vic jich je zamerne: verejne MCP servery vydavaji hotovy token, firemni
* jedou na OAuth. Kdyby slo jen jedno, cast serveru by nesla napojit.
*
* Adresa serveru je mezi udaji, ne v poli "vlastni adresa sluzby". U ostatnich
* sluzeb je adresa vlastnost sluzby a konektor ji smi jen prepsat, tady je to
* naopak: sluzba zadnou adresu nema, protoze kazda firma ma svuj server.
* Stejne to ma SMTP.
* naopak: sluzba zadnou adresu nema, kazda firma ma svuj server. Stejne to ma
* SMTP i EasyWeb nize.
*/
{
id: MCP_SERVICE_ID,
name: 'MCP server',
category: 'ai',
description:
'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.',
'Napojení na libovolný MCP server. Portál si od něj vyžádá seznam nástrojů a ty se pak dají použít jako kroky automatizace.',
icon: 'Plug',
status: 'available',
general: false,
@@ -2442,34 +2438,32 @@ 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: 'username',
label: 'Jméno',
id: 'token',
label: 'Token',
target: 'config',
name: 'username',
required: false,
secret: false,
hint: 'Jméno nebo ID aplikace, které jste dostali k serveru. Prázdné u serveru bez přihlášení.',
},
{
id: 'password',
label: 'Heslo',
target: 'config',
name: 'password',
name: 'token',
required: false,
secret: true,
hint: 'Heslo nebo tajný klíč k tomu jménu. Portál si za ně sám vyzvedne přístup a obnovuje ho.',
hint: 'Když jste od provozovatele dostali hotový token. Portál ho pošle tak, jak je, a nic dalšího neřeší.',
},
{
id: 'clientId',
label: 'ID aplikace',
target: 'config',
name: 'clientId',
required: false,
secret: false,
hint: 'Druhá možnost: server má přihlášení přes OAuth. Portál si pak přístup vyzvedne sám a obnovuje ho.',
},
{
id: 'clientSecret',
label: 'Tajemství aplikace',
target: 'config',
name: 'clientSecret',
required: false,
secret: true,
hint: 'Patří k ID aplikace.',
},
{
id: 'tokenUrl',
@@ -2495,6 +2489,81 @@ export const services: Service[] = [
actions: [],
},
/**
* MCP server EasyWebu (Centaur).
*
* Vlastni sluzba, ne varianta te predchozi. Duvod je v tom, co firma
* vyplnuje: **dostane adresu, jmeno a heslo**, zadne ID aplikace a zadny
* token. EasyWeb nema OAuth ani `.well-known`, prihlaseni je vlastni
* (`POST /login` s HTTP Basic vrati trojici tokenu).
*
* Slucovat to s obecnou sluzbou by znamenalo formular, kde je pulka poli
* vzdycky k nicemu, a hadani, ktera pulka to prave je. Rozdily jsou popsane
* v `src/mcp/dialect.ts`.
*/
{
id: MCP_EASYWEB_SERVICE_ID,
name: 'MCP EasyWeb',
category: 'ai',
description:
'Napojení na MCP server EasyWebu. Stačí adresa, jméno a heslo - portál si vyžádá seznam nástrojů a ty se dají použít jako kroky automatizace.',
icon: 'Plug',
status: 'available',
general: false,
appId: null,
transport: 'mcp',
visibility: { mode: 'everyone', tenantIds: [], userIds: [] },
credentials: [
{
id: 'serverUrl',
label: 'Adresa MCP serveru',
target: 'config',
name: 'serverUrl',
required: true,
secret: false,
hint: 'Endpoint bez koncového lomítka, například https://web.firmy.cz/centaur/mcp. Přihlašovací adresy si portál odvodí sám.',
},
{
id: 'username',
label: 'Jméno',
target: 'config',
name: 'username',
required: true,
secret: false,
hint: 'Uživatel, pod kterým se má portál k serveru hlásit.',
},
{
id: 'password',
label: 'Heslo',
target: 'config',
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.',
},
{
id: 'deviceName',
label: 'Název zařízení',
target: 'config',
name: 'deviceName',
required: false,
secret: false,
hint: 'Pod tímhle názvem uvidíte přihlášení v logu serveru. Prázdné znamená WorkNuke.',
},
{
id: 'fingerprint',
label: 'Otisk zařízení',
target: 'config',
name: 'fingerprint',
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.',
},
],
triggers: [],
actions: [],
},
/**
* Ukazka omezene viditelnosti: tuhle sluzbu vidi jen LogiTrans a spravce
* platformy. Ostatni firmy ji v katalogu vubec nedostanou, takze se ani
@@ -2765,11 +2834,11 @@ export function setScriptActions(byService: Map<string, ServiceOperation[]>): vo
*
* Plni to `src/data/mcpTools.ts`.
*/
let mcpOperations: Array<{ tenantId: string; operation: ServiceOperation }> = [];
let mcpOperations: Array<{ tenantId: string; serviceId: string; operation: ServiceOperation }> = [];
/** Nahradi cely seznam nastroju. */
export function setMcpOperations(
items: Array<{ tenantId: string; operation: ServiceOperation }>,
items: Array<{ tenantId: string; serviceId: string; operation: ServiceOperation }>,
): void {
mcpOperations = items;
}
@@ -2784,10 +2853,10 @@ const byName = (a: ServiceOperation, b: ServiceOperation): number =>
* jmenem. Nazev nastroje umi prozradit dost: `zrus_objednavku_v_soap_bridge`
* rekne o cizi firme vic, nez by melo.
*/
export function mcpActionsFor(tenantId: string | null): ServiceOperation[] {
export function mcpActionsFor(tenantId: string | null, serviceId: string): ServiceOperation[] {
if (tenantId === null) return [];
return mcpOperations
.filter((item) => item.tenantId === tenantId)
.filter((item) => item.tenantId === tenantId && item.serviceId === serviceId)
.map((item) => item.operation)
.sort(byName);
}
@@ -2798,8 +2867,11 @@ export function mcpActionsFor(tenantId: string | null): ServiceOperation[] {
* Jen pro vnitrni dohledani operace (`findOperation`, dosazovani sablon).
* Ven se to neposila - od toho je `mcpActionsFor`.
*/
function allMcpActions(): ServiceOperation[] {
return mcpOperations.map((item) => item.operation).sort(byName);
function allMcpActions(serviceId: string): ServiceOperation[] {
return mcpOperations
.filter((item) => item.serviceId === serviceId)
.map((item) => item.operation)
.sort(byName);
}
/**
@@ -2812,7 +2884,7 @@ export function actionsFor(serviceId: string): ServiceOperation[] {
if (!service) return [];
// MCP nema skripty, ma nastroje serveru. Napric firmami, viz `allMcpActions`.
if (serviceId === MCP_SERVICE_ID) return [...service.actions, ...allMcpActions()];
if (isMcpService(serviceId)) return [...service.actions, ...allMcpActions(serviceId)];
const fromScripts = scriptActions.get(serviceId);
if (!fromScripts || fromScripts.length === 0) return service.actions;
@@ -2886,8 +2958,8 @@ export function withRuntimeOptions(
*/
export function serviceCatalog(tenantId: string | null = null): Service[] {
return services.map((service) => {
if (service.id === MCP_SERVICE_ID) {
return { ...service, actions: [...service.actions, ...mcpActionsFor(tenantId)] };
if (isMcpService(service.id)) {
return { ...service, actions: [...service.actions, ...mcpActionsFor(tenantId, service.id)] };
}
return scriptActions.has(service.id) ? { ...service, actions: actionsFor(service.id) } : service;
});
+380 -182
View File
@@ -1,55 +1,53 @@
/**
* 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.
* Dva zpusoby podle toho, o jaky server jde (viz `dialect.ts`):
*
* Postup je vzdy stejny a v tomhle poradi:
* **Oficialni MCP.** Server je podle specifikace OAuth 2.1 resource server.
* Kde se prihlasit, rekne `/.well-known/oauth-protected-resource`, pripadne
* hlavicka `WWW-Authenticate` u odpovedi 401. Prihlasujeme se jako aplikace
* (`client_credentials`), protoze automatizace bezi bez cloveka u klavesnice
* a `authorization_code` potrebuje prohlizec. Kdo od serveru dostal hotovy
* token, vyplni rovnou ten - rada verejnych serveru nic jineho nenabizi.
*
* 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.
* **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.
*
* 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.
* 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
* rekne znovu. Ulozit kratkodoby token by znamenalo vsechna rizika ulozeni bez
* jakekoliv vyhody.
*/
import { config } from '../config.js';
import type { ResolvedTarget } from '../scripts/connections.js';
import { truncate } from '../scripts/util.js';
import { dialectFor } from './dialect.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.
* Bez rezervy by se stavalo, ze token projde kontrolou u nas a nez dojde na
* server, vyprsi. Minuta je vic nez kterekoliv volani.
*/
const EXPIRY_MARGIN_MS = 60_000;
/**
* Zivotnost, kdyz ji server neuvede.
* Zivotnost, kdyz ji server neuvede a neni ani v tokenu.
*
* `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.
* Drzet takovy token navzdy by znamenalo, ze po jeho expiraci prestane
* napojeni fungovat az do restartu. Petiminutovy odhad je bezpecny smerem
* dolu, nejhorsi dopad je prihlaseni 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';
| 'vyplněný token'
| 'OAuth jako aplikace'
| 'jméno a heslo, EasyWeb';
export interface Authorization {
headers: Record<string, string>;
@@ -58,18 +56,19 @@ export interface Authorization {
method: AuthMethod;
}
interface CachedToken {
interface Session {
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. */
/** Kde se obnovuje. U OAuth token endpoint, u EasyWebu adresa serveru. */
renewUrl: string;
/** Otisk udaju. Zmena hesla musi ulozene sezeni zneplatnit. */
fingerprint: string;
}
const cache = new Map<string, CachedToken>();
const cache = new Map<string, Session>();
/** Chyba prihlaseni. Nese vetu pro uzivatele, ne stack. */
export class AuthFailure extends Error {
@@ -83,22 +82,39 @@ export class AuthFailure extends Error {
}
interface Credentials {
serviceId: string;
serverUrl: string;
username: string;
password: string;
/** Vyplnena adresa pro prihlaseni. Prazdne = zjistit od serveru. */
/** Oficialni: hotovy token od provozovatele serveru. */
token: string;
/** Oficialni: identita aplikace pro OAuth. */
clientId: string;
clientSecret: string;
tokenUrl: string;
scope: string;
/** EasyWeb: prihlasovaci udaje uzivatele. */
username: string;
password: string;
deviceName: string;
fingerprint: string;
/** Nahradni otisk zarizeni, kdyz ho firma nevyplnila. */
device: string;
}
function credentialsOf(target: ResolvedTarget): Credentials {
const value = (key: string): string => (target.serviceConfig[key] ?? '').trim();
return {
serviceId: target.serviceId,
serverUrl: value('serverUrl'),
username: value('username'),
password: value('password'),
token: value('token'),
clientId: value('clientId'),
clientSecret: value('clientSecret'),
tokenUrl: value('tokenUrl'),
scope: value('scope'),
username: value('username'),
password: value('password'),
deviceName: value('deviceName'),
fingerprint: value('fingerprint'),
device: `worknuke-${target.connectorId ?? 'bez-konektoru'}`,
};
}
@@ -106,49 +122,227 @@ function credentialsOf(target: ResolvedTarget): Credentials {
* Klic do kese.
*
* Konektor, ne adresa: dve firmy mohou mit tentyz server pod jinym uctem
* a token jedne nesmi obslouzit volani druhe.
* a sezeni jedne nesmi obslouzit volani druhe.
*/
function cacheKey(target: ResolvedTarget, credentials: Credentials): string {
return target.connectorId ?? `${credentials.serverUrl}|${credentials.username}`;
return target.connectorId ?? `${credentials.serverUrl}|${credentials.username}${credentials.clientId}`;
}
/** Otisk udaju. Zmena hesla nebo adresy musi ulozeny token zahodit. */
/**
* Otisk udaju. Zmena cehokoliv z nich musi ulozene sezeni zahodit.
*
* Tajne hodnoty se do otisku nedavaji cele, staci delka a posledni znak.
* Zmenu to zachyti a hodnotu z toho slozit nejde.
*/
function fingerprintOf(credentials: Credentials): string {
const mask = (value: string): string => `${value.length}:${value.slice(-1)}`;
return [
credentials.serverUrl,
credentials.clientId,
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,
mask(credentials.token),
mask(credentials.clientSecret),
mask(credentials.password),
].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}`;
// ------------------------------------------------------- cteni odpovedi
/** Hodnota z JSONu bez ohledu na velikost pismen. Servery se v tom lisi. */
function stringField(source: Record<string, unknown> | null, ...names: string[]): string | null {
if (!source) return null;
const wanted = names.map((name) => name.toLowerCase());
for (const [key, value] of Object.entries(source)) {
if (!wanted.includes(key.toLowerCase())) continue;
if (typeof value === 'string' && value.trim() !== '') return value.trim();
}
return null;
}
/**
* Kdy token vyprsi.
*
* Tri zdroje v poradi podle spolehlivosti: `expires_in` v sekundach, datum
* v odpovedi, a nakonec `exp` z tela samotneho tokenu. To posledni je pro
* servery, ktere zivotnost nikam nepisou, ale vydavaji JWT - a je to presne
* pripad EasyWebu.
*/
function expiryFrom(body: Record<string, unknown>, token: string): number {
const seconds = body.expires_in ?? body.expiresIn ?? body.ExpiresIn;
if (typeof seconds === 'number' && seconds > 0) return Date.now() + seconds * 1000;
const stamp = stringField(body, 'expiresAt', 'expiration', 'expires', 'expiresUtc', 'expirationUtc');
if (stamp) {
const parsed = Date.parse(stamp);
if (!Number.isNaN(parsed)) return parsed;
}
const claim = expiryFromJwt(token);
if (claim !== null) return claim;
return Date.now() + DEFAULT_LIFETIME_MS;
}
/** `exp` z prostredni casti JWT. null, kdyz to JWT neni. */
function expiryFromJwt(token: string): number | null {
const parts = token.split('.');
if (parts.length < 2) return null;
try {
const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const json = Buffer.from(padded, 'base64').toString('utf8');
const payload = JSON.parse(json) as { exp?: unknown };
return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
} catch {
return null;
}
}
/** Telo odpovedi jako objekt. Vyhazuje, kdyz to JSON neni. */
async function readJson(response: Response, where: string): Promise<Record<string, unknown>> {
const raw = await response.text();
if (raw.length > config.scriptMaxResponseBytes) {
throw new AuthFailure(`Odpověď z ${where} je nad povoleným limitem.`);
}
try {
const parsed: unknown = JSON.parse(raw);
if (parsed === null || typeof parsed !== 'object') throw new Error('neni objekt');
return parsed as Record<string, unknown>;
} catch {
throw new AuthFailure(
`Odpověď z ${where} není platný JSON. Míří adresa opravdu na přihlášení?`,
truncate(raw, config.errorDetailBytes),
);
}
}
// -------------------------------------------------------------- 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);
throw new AuthFailure(
response.status === 401
? `Server jméno a heslo nepřijal (HTTP 401 z ${url}).`
: `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 {
headers: { Authorization: header },
secrets: [encoded, header, credentials.password],
method: 'HTTP Basic',
accessToken,
expiresAt: expiryFrom(body, accessToken),
refreshToken: stringField(body, 'refreshToken'),
method: 'jméno a heslo, EasyWeb',
renewUrl: credentials.serverUrl,
fingerprint: fingerprintOf(credentials),
};
}
/** Kratke GET na metadata. Chyba neni vyjimka, je to "nenaslo se". */
async function readMetadata(url: string, signal: AbortSignal): Promise<Record<string, unknown> | null> {
/**
* 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: { Accept: 'application/json' },
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". */
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;
@@ -163,8 +357,8 @@ async function readMetadata(url: string, signal: AbortSignal): Promise<Record<st
* 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.
* ma metadata jen v korenu. Zkousi se obojí - rozdil 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(/\/+$/, '');
@@ -173,16 +367,38 @@ function wellKnown(base: URL, suffix: string): string[] {
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;
/**
* Adresa metadat z hlavicky `WWW-Authenticate`.
*
* Specifikace to ma jako povinnou cestu: server u odpovedi 401 rekne, kde jsou
* jeho metadata. Pouziva se az kdyz obvykla mista selzou, protoze to stoji
* volani navic, ale bez toho by nesel napojit server, ktery si metadata dal
* jinam a oznamuje je jen timhle zpusobem.
*/
async function challengeMetadataUrl(serverUrl: string, signal: AbortSignal): Promise<string | null> {
try {
const response = await fetch(serverUrl, {
method: 'POST',
signal,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'ping', params: {} }),
});
if (response.status !== 401) return null;
const challenge = response.headers.get('www-authenticate') ?? '';
const match = /resource_metadata\s*=\s*"([^"]+)"/i.exec(challenge);
return match ? match[1] : null;
} catch {
return 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.
* Postup podle specifikace: metadata chraneneho zdroje reknou autorizacni
* server, jeho metadata rikaji token endpoint. Vraci null, kdyz autorizacni
* server neni k nalezeni.
*/
async function discoverTokenUrl(serverUrl: string, signal: AbortSignal): Promise<string | null> {
let base: URL;
@@ -192,20 +408,21 @@ async function discoverTokenUrl(serverUrl: string, signal: AbortSignal): Promise
return null;
}
// 1. Chraneny zdroj rekne, ktery autorizacni server za nim stoji.
const candidates = wellKnown(base, 'oauth-protected-resource');
const fromChallenge = await challengeMetadataUrl(serverUrl, signal);
if (fromChallenge) candidates.unshift(fromChallenge);
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;
for (const url of candidates) {
const servers = (await readMetadata(url, signal))?.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.
// 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);
@@ -222,23 +439,12 @@ async function discoverTokenUrl(serverUrl: string, signal: AbortSignal): Promise
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(
/** Jedno volani na token endpoint. */
async function tokenRequest(
tokenUrl: string,
body: Record<string, string>,
signal: AbortSignal,
): Promise<TokenResponse | null> {
): Promise<Record<string, unknown> | null> {
let response: Response;
try {
response = await fetch(tokenUrl, {
@@ -251,125 +457,99 @@ async function requestToken(
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'),
};
return readJson(response, tokenUrl);
}
/** Obnoveni pres refresh token. null = nepovedlo se, jde se prihlasit znovu. */
async function refresh(
/**
* Prihlaseni aplikace pres OAuth.
*
* `client_credentials`, protoze automatizace bezi bez cloveka u klavesnice
* a `authorization_code` potrebuje prohlizec. `resource` podle RFC 8707 je
* povinny i tehdy, kdyz ho autorizacni server nezna - vaze token na server,
* pro ktery je urceny.
*/
async function oauthLogin(
tokenUrl: string,
refreshToken: string,
credentials: Credentials,
signal: AbortSignal,
): Promise<TokenResponse | null> {
return requestToken(
): Promise<Session> {
const body = await tokenRequest(
tokenUrl,
{
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: credentials.username,
client_secret: credentials.password,
grant_type: 'client_credentials',
client_id: credentials.clientId,
client_secret: credentials.clientSecret,
resource: credentials.serverUrl,
...(credentials.scope ? { scope: credentials.scope } : {}),
},
signal,
);
const accessToken = body ? stringField(body, 'access_token', 'accessToken') : null;
if (!body || !accessToken) {
throw new AuthFailure(
`Server ${tokenUrl} přihlášení aplikace nepřijal. Ověřte ID a tajemství, ` +
'případně vyplňte adresu pro přihlášení ručně.',
);
}
return {
accessToken,
expiresAt: expiryFrom(body, accessToken),
refreshToken: stringField(body, 'refresh_token', 'refreshToken'),
method: 'OAuth jako aplikace',
renewUrl: tokenUrl,
fingerprint: fingerprintOf(credentials),
};
}
/**
* 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,
/** Obnova pres `refresh_token`. null = nepovedlo se, jde se prihlasit znovu. */
async function oauthRenew(
session: Session,
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 };
): Promise<Session | null> {
if (!session.refreshToken) return null;
const asApplication = await requestToken(
tokenUrl,
const body = await tokenRequest(
session.renewUrl,
{
grant_type: 'client_credentials',
client_id: credentials.username,
client_secret: credentials.password,
...scope,
...resource,
grant_type: 'refresh_token',
refresh_token: session.refreshToken,
client_id: credentials.clientId,
client_secret: credentials.clientSecret,
...(credentials.scope ? { scope: credentials.scope } : {}),
},
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' };
}
const accessToken = body ? stringField(body, 'access_token', 'accessToken') : null;
if (!body || !accessToken) return null;
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ě.',
);
return {
...session,
accessToken,
expiresAt: expiryFrom(body, accessToken),
refreshToken: stringField(body, 'refresh_token', 'refreshToken') ?? session.refreshToken,
fingerprint: fingerprintOf(credentials),
};
}
// ----------------------------------------------------------------- ven
/**
* Hlavicky pro volani MCP serveru.
*
* Uvnitr se resi cely zivotni cyklus tokenu: platny se pouzije, prosly se
* Uvnitr se resi cely zivotni cyklus: platny token se pouzije, prosly se
* obnovi nebo vymeni za novy. Volajici o tokenu nevi.
*/
export async function authorize(
@@ -377,9 +557,20 @@ export async function authorize(
signal: AbortSignal,
): Promise<Authorization> {
const credentials = credentialsOf(target);
const dialect = dialectFor(credentials.serviceId);
// Server bez prihlaseni je bezny, hlavne u verejnych a vnitrofiremnich.
if (credentials.username === '' && credentials.password === '') {
/*
* 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 !== '') {
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) {
return { headers: {}, secrets: [], method: 'bez přihlášení' };
}
@@ -391,31 +582,38 @@ export async function authorize(
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);
}
// Vyprsel. Obnova je levnejsi nez cele prihlaseni znovu.
const renewed =
dialect.auth === 'easyweb'
? await easyWebRenew(cached, credentials, signal)
: await oauthRenew(cached, credentials, signal);
if (renewed) {
cache.set(key, renewed);
return bearer(renewed.accessToken, renewed.method);
}
}
const tokenUrl = credentials.tokenUrl || (await discoverTokenUrl(credentials.serverUrl, signal));
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);
}
/*
* 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);
cache.set(key, session);
return bearer(session.accessToken, session.method);
}
/**
* Zahodi ulozeny token.
* Zahodi ulozene sezeni.
*
* Vola se, kdyz server odmitne token, ktery jsme povazovali za platny - treba
* proto, ze mu nekdo na druhe strane odebral opravneni driv, nez vyprsel.
+24 -10
View File
@@ -26,15 +26,15 @@ 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 { dialectFor, type McpDialect } from './dialect.js';
/**
* Verze protokolu, kterou umime.
* Verzi protokolu urcuje druh serveru (`dialect.ts`).
*
* Server smi odpovedet jinou - pak plati jeho a posila se dal v hlavicce
* 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.
*/
const PROTOCOL_VERSION = '2025-06-18';
/** Kdo se predstavi serveru. Nektere servery si to pisou do logu. */
const CLIENT_INFO = { name: 'worknuke', version: '1' };
@@ -173,6 +173,8 @@ interface Session {
redact: (value: string) => string;
/** Jak se portal prihlasil. Jde to do hlasky u konektoru. */
authMethod: AuthMethod;
/** Cim se dany druh serveru lisi. */
dialect: McpDialect;
}
let nextId = 1;
@@ -202,10 +204,18 @@ async function rpc(
signal,
headers: {
'Content-Type': 'application/json',
// Obojí, protoze server si vybira, jestli odpovi telem nebo streamem.
Accept: 'application/json, text/event-stream',
/*
* 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.
*/
Accept: session.dialect.acceptEventStream
? 'application/json, text/event-stream'
: 'application/json',
'MCP-Protocol-Version': session.protocolVersion,
...(session.sessionId ? { 'Mcp-Session-Id': session.sessionId } : {}),
...(session.dialect.useSessionHeader && session.sessionId
? { 'Mcp-Session-Id': session.sessionId }
: {}),
...session.headers,
},
body: JSON.stringify(body),
@@ -220,8 +230,10 @@ async function rpc(
}
// Sezeni zaklada server pri prvnim volani a pak ho vyzaduje u dalsich.
const issued = response.headers.get('mcp-session-id');
if (issued) session.sessionId = issued;
if (session.dialect.useSessionHeader) {
const issued = response.headers.get('mcp-session-id');
if (issued) session.sessionId = issued;
}
const raw = await response.text();
if (raw.length > config.scriptMaxResponseBytes) {
@@ -285,15 +297,17 @@ async function rpc(
*/
async function buildSession(target: ResolvedTarget, signal: AbortSignal): Promise<Session> {
const url = serverUrl(target);
const dialect = dialectFor(target.serviceId);
const auth = await authorize(target, signal);
return {
url,
headers: { ...target.headers, ...auth.headers },
sessionId: null,
protocolVersion: PROTOCOL_VERSION,
protocolVersion: dialect.protocolVersion,
redact: createRedactor([...targetSecrets(target), ...auth.secrets]),
authMethod: auth.method,
dialect,
};
}
@@ -308,7 +322,7 @@ async function handshake(session: Session, signal: AbortSignal): Promise<string>
const result = (await rpc(
session,
'initialize',
{ protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO },
{ protocolVersion: session.dialect.protocolVersion, capabilities: {}, clientInfo: CLIENT_INFO },
signal,
)) as { protocolVersion?: string; serverInfo?: { name?: string; version?: string } };
+79
View File
@@ -0,0 +1,79 @@
/**
* Dva druhy MCP serveru.
*
* MCP je standard, ale prihlaseni k nemu ne. Oficialni specifikace stoji na
* OAuth 2.1 vcetne objevovani autorizacniho serveru pres `.well-known`.
* EasyWeb (Centaur) ma **vlastni prihlaseni**: `POST /login` s HTTP Basic vrati
* trojici tokenu a ty se obnovuji vlastnimi endpointy. Zadny OAuth, zadne
* `.well-known`.
*
* Rozdily nejsou jen v prihlaseni, proto vlastni soubor. Kdyby se resily
* podminkami rozesetymi po klientovi, pribyl by u kazdeho dalsiho serveru
* dalsi `if` na jinem miste.
*
* Sluzby jsou dve, protoze **firma pri zakladani konektoru vyplnuje neco
* jineho**: u oficialniho ID a tajemstvi aplikace, u EasyWebu jmeno, heslo
* a nazev zarizeni. Hadat to z adresy nejde a nabidnout obojí najednou by
* znamenalo formular, kde je pulka poli vzdycky k nicemu.
*/
/** Sluzba podle oficialni specifikace MCP. */
export const MCP_SERVICE_ID = 'mcp';
/** Sluzba pro MCP server EasyWebu, tedy Centaur. */
export const MCP_EASYWEB_SERVICE_ID = 'mcp-easyweb';
export const MCP_SERVICE_IDS = [MCP_SERVICE_ID, MCP_EASYWEB_SERVICE_ID] as const;
export type McpAuthKind = 'oauth' | 'easyweb';
export interface McpDialect {
auth: McpAuthKind;
/**
* Verze protokolu v `initialize`.
*
* Plati verze, kterou vrati server. Tohle je jen navrh - u EasyWebu ale
* navic **musi sedet i v hlavicce** dalsich volani, protoze si ji server
* kontroluje proti sve konstante.
*/
protocolVersion: string;
/**
* Prijme klient odpoved jako SSE stream?
*
* EasyWeb ma SSE zatim jen jako zakomentovany kod, takze mu nema smysl
* rikat, ze stream umime.
*/
acceptEventStream: boolean;
/**
* Posila se zpatky `Mcp-Session-Id` z odpovedi?
*
* Oficialni transport na nem stoji. EasyWeb sezeni drzi u tokenu, hlavicku
* nevydava a poslat mu ji je zbytecne.
*/
useSessionHeader: boolean;
}
const dialects: Record<string, McpDialect> = {
[MCP_SERVICE_ID]: {
auth: 'oauth',
protocolVersion: '2025-06-18',
acceptEventStream: true,
useSessionHeader: true,
},
[MCP_EASYWEB_SERVICE_ID]: {
auth: 'easyweb',
// Server si po handshaku kontroluje, ze hlavicka sedi na jeho konstantu.
protocolVersion: '2025-11-25',
acceptEventStream: false,
useSessionHeader: false,
},
};
export function isMcpService(serviceId: string): boolean {
return serviceId in dialects;
}
/** Neznama sluzba dostane oficialni chovani, tedy to podle standardu. */
export function dialectFor(serviceId: string): McpDialect {
return dialects[serviceId] ?? dialects[MCP_SERVICE_ID];
}
+51
View File
@@ -188,6 +188,57 @@ export function outputsFromTool(tool: McpTool): ProvidedField[] {
return [...alwaysOutputs, ...own];
}
/**
* Jmeno parametru, kterym se nastroj strankuje.
*
* Nastroj muze vracet data po strankach: v odpovedi je kurzor na dalsi a ten
* se posle zpatky v argumentu. Vzorem je `db/search` v EasyWebu, ktery ma
* `limit` a `cursor` a vraci `NextCursor`.
*
* Pozna se to podle jmena, protoze schema JSON Schema nema jak rict "tohle je
* kurzor". Je to dohoda, ne standard - proto se hleda jen presne `cursor`,
* a ne cokoliv, co to slovo obsahuje. Nastroj s parametrem `cursorColor` by
* jinak zacal delat neco jineho, nez co ma.
*/
export function cursorFieldOf(schema: JsonSchema | undefined): string | null {
for (const [name, property] of propertiesOf(schema)) {
if (name.toLowerCase() !== 'cursor') continue;
const type = typeOf(property);
if (type === 'string' || type === 'unknown') return name;
}
return null;
}
/**
* Kurzor na dalsi stranku z odpovedi nastroje.
*
* Jmeno se opet lisi server od serveru, proto vic variant. Prazdna hodnota
* a `null` znamenaji konec - server rika, ze dalsi stranka neni.
*/
export function nextCursorFrom(structured: Record<string, unknown> | null): string | null {
if (!structured) return null;
for (const [key, value] of Object.entries(structured)) {
if (!['nextcursor', 'next_cursor', 'cursor'].includes(key.toLowerCase())) continue;
if (typeof value === 'string' && value.trim() !== '') return value;
}
return null;
}
/**
* Prvni pole v odpovedi, tedy to, co se pri strankovani sklada dohromady.
*
* Nastroj vraci vedle kurzoru obvykle jednu kolekci - zaznamy, polozky,
* vysledky. Jak se jmenuje, urcuje server, takze se bere prvni, ktera je
* seznam. Kdyz zadna neni, strankovani slozi aspon texty.
*/
export function rowsFrom(structured: Record<string, unknown> | null): unknown[] | null {
if (!structured) return null;
for (const value of Object.values(structured)) {
if (Array.isArray(value)) return value;
}
return null;
}
export interface ArgumentsResult {
args: Record<string, unknown>;
/** Co se nepovedlo prevest. Prazdne = da se volat. */
+3 -2
View File
@@ -1790,8 +1790,9 @@ export function buildOpenApiDocument() {
tags: ['Konektory'],
summary: 'Nacist nastroje MCP serveru',
description:
'Zepta se MCP serveru na tools/list a ulozi vysledek ke konektoru. Je to jedina ' +
'sluzba, u ktere seznam operaci neurcuje katalog, ale az sam server - teprve tim ' +
'Zepta se MCP serveru na tools/list a ulozi vysledek ke konektoru. Plati pro obe ' +
'sluzby MCP (obecnou i EasyWeb) - jsou to jedine sluzby, u kterych seznam operaci ' +
'neurcuje katalog, ale az sam server - teprve tim ' +
'vzniknou kroky, ktere jde davat do automatizaci, vcetne toho, jake promenne ' +
'prijimaji a jake vraceji. Zaroven to je overeni konektoru, proto se zapisuje do ' +
'historie: kdyz server odpovi seznamem, adresa i token sedi. Cteci volani, nic ' +
+2 -2
View File
@@ -35,7 +35,6 @@ import {
import {
canSeeService,
findService,
MCP_SERVICE_ID,
serviceCatalog,
serviceCategories,
visibleServices,
@@ -46,6 +45,7 @@ import { egressIp } from '../data/egressIp.js';
import { smtpSettings, smtpTargetUrl, verifySmtp } from '../mail/smtp.js';
import { listTools } from '../mcp/client.js';
import { forgetMcpTools, rememberMcpTools } from '../data/mcpTools.js';
import { isMcpService } from '../mcp/dialect.js';
import { resolveTarget, serviceBaseUrl, targetSecrets } from '../scripts/connections.js';
import { createHttp } from '../scripts/http.js';
import { ScriptError } from '../scripts/types.js';
@@ -262,7 +262,7 @@ connectorsRouter.patch('/:id', async (req, res) => {
}
// Nazev konektoru je v nazvu kazdeho jeho nastroje ve vyberu kroku. Bez
// tohohle by tam po prejmenovani zustal stary az do restartu.
if (updated.serviceId === MCP_SERVICE_ID) rememberMcpTools(updated);
if (isMcpService(updated.serviceId)) rememberMcpTools(updated);
return res.json(toPublicConnector(updated));
});
+70 -19
View File
@@ -10,10 +10,10 @@
*/
import { defaultConnectorFor, getConnector } from '../data/connectorStore.js';
import { findMcpTool, parseOperationId } from '../data/mcpTools.js';
import { MCP_SERVICE_ID } from '../data/services.js';
import { callTool } from '../mcp/client.js';
import { argumentsFrom } from '../mcp/schema.js';
import { ALL_PAGES_INPUT, findMcpTool, MAX_TOOL_PAGES, parseOperationId } from '../data/mcpTools.js';
import { isMcpService } from '../mcp/dialect.js';
import { callTool, type McpCallResult } from '../mcp/client.js';
import { argumentsFrom, cursorFieldOf, nextCursorFrom, rowsFrom } from '../mcp/schema.js';
import { truncate } from '../scripts/util.js';
import { createIncident } from '../data/incidentStore.js';
import { sendMail } from '../mail/smtp.js';
@@ -781,28 +781,79 @@ async function runMcpTool(
};
}
const outcome = await callTool(target, parsed.toolName, args);
if (!outcome.ok || !outcome.value) {
return { ok: false, summary: outcome.message, detail: outcome.detail, outputs: {} };
/*
* Strankovani.
*
* Nastroj muze vracet data po strankach a kurzor na dalsi je hodnota
* z odpovedi - v dobe stavby stromu ji nikdo nezna, takze ji neslo vyplnit
* dopredu. Kdyz je prepinac zapnuty, projde se to za nas.
*/
const cursorField = cursorFieldOf(found.tool.inputSchema);
const allPages = cursorField !== null && (inputs[ALL_PAGES_INPUT] ?? '').trim() === 'true';
const texts: string[] = [];
const pages: Array<Record<string, unknown> | null> = [];
const items: unknown[] = [];
let last: McpCallResult | null = null;
let cursor: string | null = null;
let truncated = false;
for (let page = 0; page < (allPages ? MAX_TOOL_PAGES : 1); page += 1) {
const pageArgs = cursor !== null && cursorField ? { ...args, [cursorField]: cursor } : args;
const outcome = await callTool(target, parsed.toolName, pageArgs);
if (!outcome.ok || !outcome.value) {
return { ok: false, summary: outcome.message, detail: outcome.detail, outputs: {} };
}
last = outcome.value;
if (last.text !== '') texts.push(last.text);
pages.push(last.structured);
const rows = rowsFrom(last.structured);
if (rows) items.push(...rows);
// Chyba nastroje zastavi strankovani. Volat dalsi stranku po tom, co server
// rekl, ze se neco nepovedlo, znamena jen vic volani a stejny vysledek.
if (!allPages || last.isError) break;
const next = nextCursorFrom(last.structured);
// Stejny kurzor podruhe by znamenal nekonecnou smycku.
if (!next || next === cursor) break;
cursor = next;
// Strop je dosazeny a server porad nabizi dalsi. Rekne se to nahlas,
// protoze tichy vysledek by vypadal jako uplny.
if (page === MAX_TOOL_PAGES - 1) truncated = true;
}
const value = outcome.value;
if (!last) {
return { ok: false, summary: 'nástroj nevrátil žádnou odpověď', detail: null, outputs: {} };
}
const text = texts.join('\n');
const summary = last.isError
? `nástroj ${parsed.toolName} skončil chybou`
: allPages
? `nástroj ${parsed.toolName} doběhl, stránek: ${pages.length}`
: `nástroj ${parsed.toolName} doběhl`;
return {
ok: !value.isError,
summary: value.isError
? `nástroj ${parsed.toolName} skončil chybou`
: `nástroj ${parsed.toolName} doběhl`,
detail: value.text === '' ? null : truncate(value.text, 600),
ok: !last.isError,
summary: truncated ? `${summary}, strop stránek vyčerpán` : summary,
detail: text === '' ? null : truncate(text, 600),
/*
* Strukturovana odpoved se rozbaluje do vystupu, aby na ni sla postavit
* podminka bez psani cesty. Spolecne tri hodnoty se pisou az po ni: `text`
* podminka bez psani cesty. Spolecne hodnoty se pisou az po ni: `text`
* znamena text odpovedi vzdycky, at uz si nastroj rika co chce.
*/
outputs: {
...(value.structured ?? {}),
text: value.text,
isError: value.isError,
structured: value.structured,
...(last.structured ?? {}),
text,
isError: last.isError,
structured: last.structured,
...(allPages
? { items, pages, pageCount: pages.length, truncated }
: {}),
},
};
}
@@ -811,6 +862,6 @@ async function runMcpTool(
export function findBuiltinStep(serviceId: string, operationId: string): Handler | undefined {
// MCP nema pevny seznam operaci, nastroje rekne az server. Klic by tedy
// nebylo podle ceho slozit - obsluha je jedna a nastroj si najde sama.
if (serviceId === MCP_SERVICE_ID) return runMcpTool;
if (isMcpService(serviceId)) return runMcpTool;
return handlers[`${serviceId}/${operationId}`];
}