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:
co-authored by
Claude Opus 5
parent
92fecca70c
commit
81e4348ad8
+65
-8
@@ -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
@@ -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
@@ -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
@@ -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 } };
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
@@ -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 ' +
|
||||
|
||||
@@ -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
@@ -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}`];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user