@hubex/mcp 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/.env.example +54 -0
  2. package/CONNECTING.md +260 -0
  3. package/README.md +266 -0
  4. package/dist/auth.js +226 -0
  5. package/dist/config.js +120 -0
  6. package/dist/generated/manifest.js +3643 -0
  7. package/dist/guides/field-notes.json +27 -0
  8. package/dist/guides/loader.js +32 -0
  9. package/dist/guides/service-map.js +6 -0
  10. package/dist/http.js +93 -0
  11. package/dist/index/store.js +134 -0
  12. package/dist/index/types.js +1 -0
  13. package/dist/index.js +20 -0
  14. package/dist/paths.js +20 -0
  15. package/dist/pii/fields.js +100 -0
  16. package/dist/pii/mask.js +50 -0
  17. package/dist/pii/strategies.js +88 -0
  18. package/dist/schema/build-index.js +67 -0
  19. package/dist/schema/deref.js +87 -0
  20. package/dist/schema/describe.js +65 -0
  21. package/dist/server.js +62 -0
  22. package/dist/token-prompt.js +41 -0
  23. package/dist/tools/curated.js +151 -0
  24. package/dist/tools/discovery.js +187 -0
  25. package/dist/tools/guides.js +126 -0
  26. package/dist/tools/registry.js +37 -0
  27. package/dist/tools/request.js +170 -0
  28. package/dist/tools/types.js +1 -0
  29. package/docs/guides/assets.md +334 -0
  30. package/docs/guides/attributes.md +125 -0
  31. package/docs/guides/checklisttemplates.md +154 -0
  32. package/docs/guides/companies.md +57 -0
  33. package/docs/guides/dictionaries.md +64 -0
  34. package/docs/guides/lifecycle.md +263 -0
  35. package/docs/guides/materials.md +135 -0
  36. package/docs/guides/notifications.md +184 -0
  37. package/docs/guides/roles.md +125 -0
  38. package/docs/guides/sla.md +130 -0
  39. package/docs/guides/start.md +67 -0
  40. package/docs/guides/taskchecklists.md +149 -0
  41. package/docs/guides/taskcreate.md +260 -0
  42. package/docs/guides/taskedit.md +277 -0
  43. package/docs/guides/tasktypes.md +156 -0
  44. package/docs/guides/users.md +71 -0
  45. package/generated/index.dev.json +18776 -0
  46. package/generated/index.prod.json +18858 -0
  47. package/package.json +48 -0
  48. package/swagger/dev/ADM.json +27777 -0
  49. package/swagger/dev/AUTH.json +1739 -0
  50. package/swagger/dev/AUTHN.json +1250 -0
  51. package/swagger/dev/AUTHZ.json +1404 -0
  52. package/swagger/dev/CM.json +309 -0
  53. package/swagger/dev/COMMON.json +6543 -0
  54. package/swagger/dev/ES.json +28029 -0
  55. package/swagger/dev/EXPORT.json +4575 -0
  56. package/swagger/dev/IMPORT.json +1479 -0
  57. package/swagger/dev/LIC.json +224 -0
  58. package/swagger/dev/MSG.json +7883 -0
  59. package/swagger/dev/NEWS.json +348 -0
  60. package/swagger/dev/PA.json +5981 -0
  61. package/swagger/dev/PMP.json +3196 -0
  62. package/swagger/dev/PROXY.json +416 -0
  63. package/swagger/dev/REPORT.json +3921 -0
  64. package/swagger/dev/SC.json +3771 -0
  65. package/swagger/dev/SLA.json +2837 -0
  66. package/swagger/dev/TSTG.json +4981 -0
  67. package/swagger/dev/UI.json +4720 -0
  68. package/swagger/dev/WH.json +16796 -0
  69. package/swagger/dev/WORK.json +36024 -0
  70. package/swagger/dev/WSP.json +1612 -0
  71. package/swagger/prod/ADM.json +27777 -0
  72. package/swagger/prod/AUTH.json +1308 -0
  73. package/swagger/prod/AUTHN.json +2710 -0
  74. package/swagger/prod/AUTHZ.json +896 -0
  75. package/swagger/prod/CM.json +162 -0
  76. package/swagger/prod/COMMON.json +4910 -0
  77. package/swagger/prod/ES.json +28029 -0
  78. package/swagger/prod/EXPORT.json +3091 -0
  79. package/swagger/prod/LIC.json +123 -0
  80. package/swagger/prod/MSG.json +6239 -0
  81. package/swagger/prod/NEWS.json +295 -0
  82. package/swagger/prod/PA.json +5123 -0
  83. package/swagger/prod/PMP.json +2978 -0
  84. package/swagger/prod/PROXY.json +250 -0
  85. package/swagger/prod/REPORT.json +3729 -0
  86. package/swagger/prod/SC.json +3771 -0
  87. package/swagger/prod/SLA.json +2201 -0
  88. package/swagger/prod/TSTG.json +4220 -0
  89. package/swagger/prod/UI.json +3879 -0
  90. package/swagger/prod/WH.json +16730 -0
  91. package/swagger/prod/WORK.json +35994 -0
  92. package/swagger/prod/WSP.json +1468 -0
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Разворачивание OpenAPI-схем в плоский JSON Schema с защитой от циклических
3
+ * `$ref` и от чрезмерной глубины. Используется генератором индекса и рантайм-
4
+ * инструментом describe_endpoint.
5
+ */
6
+ const SCALAR_KEYS = [
7
+ "type", "format", "enum", "description", "nullable",
8
+ "minimum", "maximum", "minLength", "maxLength", "pattern", "default", "example",
9
+ ];
10
+ export function resolvePointer(doc, ref) {
11
+ if (!ref.startsWith("#/"))
12
+ return undefined;
13
+ const parts = ref.slice(2).split("/");
14
+ let cur = doc;
15
+ for (const p of parts) {
16
+ cur = cur?.[decodeURIComponent(p.replace(/~1/g, "/").replace(/~0/g, "~"))];
17
+ if (cur === undefined)
18
+ return undefined;
19
+ }
20
+ return cur;
21
+ }
22
+ /**
23
+ * Dereference an OpenAPI schema node into a plain JSON Schema, guarding against
24
+ * recursive `$ref` cycles and runaway depth.
25
+ */
26
+ export function deref(node, doc, stack = new Set(), depth = 0) {
27
+ if (!node || depth > 25)
28
+ return { type: "object" };
29
+ if (node.$ref) {
30
+ const ref = node.$ref;
31
+ if (stack.has(ref))
32
+ return { type: "object", description: "(recursive reference omitted)" };
33
+ const target = resolvePointer(doc, ref);
34
+ if (!target)
35
+ return { type: "object" };
36
+ const nextStack = new Set(stack);
37
+ nextStack.add(ref);
38
+ return deref(target, doc, nextStack, depth + 1);
39
+ }
40
+ // allOf → merge object subschemas.
41
+ if (Array.isArray(node.allOf)) {
42
+ const merged = { type: "object", properties: {}, required: [] };
43
+ for (const sub of node.allOf) {
44
+ const r = deref(sub, doc, stack, depth + 1);
45
+ if (r.properties)
46
+ Object.assign(merged.properties, r.properties);
47
+ if (Array.isArray(r.required))
48
+ merged.required.push(...r.required);
49
+ if (r.description && !merged.description)
50
+ merged.description = r.description;
51
+ }
52
+ if (merged.required.length === 0)
53
+ delete merged.required;
54
+ if (Object.keys(merged.properties).length === 0)
55
+ delete merged.properties;
56
+ return merged;
57
+ }
58
+ const out = {};
59
+ for (const k of SCALAR_KEYS) {
60
+ if (node[k] !== undefined)
61
+ out[k] = node[k];
62
+ }
63
+ if (node.oneOf || node.anyOf) {
64
+ // Keep it simple for LLM consumption: fall back to first alternative's shape.
65
+ const alt = (node.oneOf ?? node.anyOf)[0];
66
+ return { ...out, ...deref(alt, doc, stack, depth + 1) };
67
+ }
68
+ if (node.properties) {
69
+ out.type = out.type ?? "object";
70
+ out.properties = {};
71
+ for (const [key, child] of Object.entries(node.properties)) {
72
+ out.properties[key] = deref(child, doc, stack, depth + 1);
73
+ }
74
+ if (Array.isArray(node.required))
75
+ out.required = node.required;
76
+ }
77
+ if (node.items) {
78
+ out.type = out.type ?? "array";
79
+ out.items = deref(node.items, doc, stack, depth + 1);
80
+ }
81
+ if (node.additionalProperties && typeof node.additionalProperties === "object") {
82
+ out.additionalProperties = deref(node.additionalProperties, doc, stack, depth + 1);
83
+ }
84
+ if (!out.type && !out.properties && !out.items && !out.enum)
85
+ out.type = "object";
86
+ return out;
87
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Ленивая выдача полной JSON Schema одного эндпоинта. Swagger читается по
3
+ * требованию и кэшируется на процесс: индекс дёшев, схемы — нет.
4
+ */
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { deref, resolvePointer } from "./deref.js";
8
+ export class SchemaProvider {
9
+ swaggerDir;
10
+ cache = new Map();
11
+ constructor(swaggerDir) {
12
+ this.swaggerDir = swaggerDir;
13
+ }
14
+ /** Сервисы, чей swagger уже прочитан (для тестов и диагностики). */
15
+ loadedServices() {
16
+ return [...this.cache.keys()].sort();
17
+ }
18
+ describe(entry) {
19
+ const doc = this.load(entry.service);
20
+ const op = doc.paths?.[entry.path]?.[entry.method.toLowerCase()];
21
+ if (!op) {
22
+ throw new Error(`Эндпоинт ${entry.id} есть в индексе, но отсутствует в swagger. Перегенерируй индекс: npm run generate`);
23
+ }
24
+ const pathParams = {};
25
+ const queryParams = {};
26
+ const seen = new Set();
27
+ for (const raw of (op.parameters ?? [])) {
28
+ const param = raw.$ref ? (resolvePointer(doc, raw.$ref) ?? {}) : raw;
29
+ if (param.in !== "path" && param.in !== "query")
30
+ continue;
31
+ if (seen.has(param.name))
32
+ continue;
33
+ seen.add(param.name);
34
+ const schema = deref(param.schema ?? { type: "string" }, doc);
35
+ if (param.description)
36
+ schema.description = param.description;
37
+ (param.in === "path" ? pathParams : queryParams)[param.name] = schema;
38
+ }
39
+ const bodySchema = op.requestBody?.content?.["application/json"]?.schema;
40
+ return {
41
+ id: entry.id,
42
+ service: entry.service,
43
+ method: entry.method,
44
+ path: entry.path,
45
+ summary: entry.summary,
46
+ pathParams,
47
+ queryParams,
48
+ body: bodySchema ? deref(bodySchema, doc) : undefined,
49
+ bodyRequired: Boolean(bodySchema) && op.requestBody?.required === true,
50
+ };
51
+ }
52
+ load(service) {
53
+ const cached = this.cache.get(service);
54
+ if (cached)
55
+ return cached;
56
+ const file = join(this.swaggerDir, `${service}.json`);
57
+ if (!existsSync(file)) {
58
+ throw new Error(`Нет кэша swagger для сервиса ${service}: ожидался файл ${file}. ` +
59
+ `Либо добавь файл в каталог swagger/, либо исключи сервис ${service} из HUBEX_SERVICES.`);
60
+ }
61
+ const doc = JSON.parse(readFileSync(file, "utf8"));
62
+ this.cache.set(service, doc);
63
+ return doc;
64
+ }
65
+ }
package/dist/server.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * MCP server wiring: exposes the tool registry over the Model Context Protocol.
3
+ */
4
+ import { readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
8
+ import { createAuthProvider } from "./auth.js";
9
+ import { createElicitPrompter } from "./token-prompt.js";
10
+ import { HubexClient } from "./http.js";
11
+ import { EndpointIndex, loadIndexFile } from "./index/store.js";
12
+ import { SchemaProvider } from "./schema/describe.js";
13
+ import { buildRegistry } from "./tools/registry.js";
14
+ import { PACKAGE_ROOT, indexFileFor, swaggerDirFor } from "./paths.js";
15
+ /** Версия сервера = версия пакета: одна точка правды при релизе. */
16
+ function packageVersion() {
17
+ const pkg = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
18
+ return pkg.version ?? "0.0.0";
19
+ }
20
+ export function createServer(config) {
21
+ const auth = createAuthProvider(config);
22
+ const client = new HubexClient(config, auth);
23
+ const index = new EndpointIndex(loadIndexFile(indexFileFor(config.swaggerCatalog)), {
24
+ methods: config.methods,
25
+ services: config.services,
26
+ });
27
+ const schemas = new SchemaProvider(swaggerDirFor(config.swaggerCatalog));
28
+ const registry = buildRegistry({
29
+ client,
30
+ config,
31
+ auth,
32
+ index,
33
+ schemas,
34
+ });
35
+ const server = new Server({ name: "hubex-mcp", version: packageVersion() }, { capabilities: { tools: {} } });
36
+ // Провайдер создаётся раньше сервера, поэтому способ ввода вносится сюда, а не в конструктор.
37
+ if (config.authMode === "token")
38
+ auth.setPrompter(createElicitPrompter(server));
39
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
40
+ tools: [...registry.values()].map((t) => ({
41
+ name: t.definition.name,
42
+ description: t.definition.description,
43
+ inputSchema: t.definition.inputSchema,
44
+ })),
45
+ }));
46
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
47
+ const tool = registry.get(req.params.name);
48
+ if (!tool) {
49
+ return {
50
+ content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }],
51
+ isError: true,
52
+ };
53
+ }
54
+ const args = (req.params.arguments ?? {});
55
+ const result = await tool.execute(args);
56
+ return {
57
+ content: [{ type: "text", text: result.text }],
58
+ isError: result.isError ?? false,
59
+ };
60
+ });
61
+ return server;
62
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Asking the user for an access token in `HUBEX_AUTH_MODE=token`.
3
+ *
4
+ * The primary channel is MCP elicitation: the client shows an input field and
5
+ * the token never passes through the model's context. Clients without that
6
+ * capability fall back to the `hubex_set_token` tool.
7
+ */
8
+ export const SET_TOKEN_TOOL = "hubex_set_token";
9
+ const PROMPT_MESSAGE = "Нужен доступ к HubEx. Вставь access-токен (JWT без префикса «Bearer ») — " +
10
+ "по нему будет получен refresh-токен, дальше сессия продлевается сама.";
11
+ export function createElicitPrompter(server) {
12
+ return async () => {
13
+ if (!server.getClientCapabilities()?.elicitation) {
14
+ throw new Error("Клиент не поддерживает интерактивный ввод (elicitation). " +
15
+ `Вызови инструмент ${SET_TOKEN_TOOL} и передай access-токен HubEx.`);
16
+ }
17
+ const result = await server.elicitInput({
18
+ message: PROMPT_MESSAGE,
19
+ requestedSchema: {
20
+ type: "object",
21
+ properties: {
22
+ token: {
23
+ type: "string",
24
+ title: "Access-токен HubEx",
25
+ description: "JWT без префикса «Bearer ».",
26
+ minLength: 1,
27
+ },
28
+ },
29
+ required: ["token"],
30
+ },
31
+ });
32
+ if (result.action !== "accept") {
33
+ throw new Error("Ввод токена отменён — авторизоваться в HubEx не удалось.");
34
+ }
35
+ const token = result.content?.token;
36
+ if (typeof token !== "string" || token.trim() === "") {
37
+ throw new Error("Токен не введён.");
38
+ }
39
+ return token.trim();
40
+ };
41
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Hand-written convenience tools layered on top of the generated ones.
3
+ */
4
+ import { decodeJwt } from "../auth.js";
5
+ import { SET_TOKEN_TOOL } from "../token-prompt.js";
6
+ import { okResult, failResult } from "./registry.js";
7
+ /** Marker prepended to test data so it is easy to find and clean up later. */
8
+ export const TEST_MARKER = "[MCP-TEST]";
9
+ function whoami(config, auth) {
10
+ return {
11
+ definition: {
12
+ name: "hubex_whoami",
13
+ description: "Показать текущую конфигурацию подключения к HubEx: окружение, tenant, режим авторизации и срок действия токена. Полезно для диагностики перед работой с данными.",
14
+ inputSchema: { type: "object", properties: {} },
15
+ },
16
+ async execute() {
17
+ try {
18
+ const token = await auth.getAccessToken();
19
+ const claims = decodeJwt(token);
20
+ const exp = claims?.exp ? new Date(claims.exp * 1000).toISOString() : undefined;
21
+ return okResult({
22
+ env: config.env,
23
+ apiBaseUrl: config.apiBaseUrl,
24
+ applicationId: config.applicationId,
25
+ authMode: config.authMode,
26
+ readonly: config.readonly,
27
+ maskPii: config.maskPii,
28
+ tenantId: auth.getTenantId(),
29
+ account: claims?.sub,
30
+ accountId: claims?.AccountID,
31
+ tenantMemberId: claims?.TenantMemberID,
32
+ tokenExpiresAt: exp,
33
+ tokenExpired: exp ? Date.now() >= claims.exp * 1000 : undefined,
34
+ });
35
+ }
36
+ catch (err) {
37
+ return failResult(err);
38
+ }
39
+ },
40
+ };
41
+ }
42
+ /**
43
+ * Fallback for clients without elicitation support: the token arrives as a tool
44
+ * argument instead of through an input field.
45
+ */
46
+ function setToken(auth) {
47
+ return {
48
+ definition: {
49
+ name: SET_TOKEN_TOOL,
50
+ description: "Передать access-токен HubEx (JWT без префикса «Bearer »). Нужен, если клиент не умеет " +
51
+ "запрашивать ввод сам: попроси токен у пользователя и передай его сюда. По токену будет " +
52
+ "получен refresh-токен, дальше сессия продлевается автоматически.",
53
+ inputSchema: {
54
+ type: "object",
55
+ properties: {
56
+ token: { type: "string", description: "Access-токен HubEx (JWT), без префикса «Bearer »." },
57
+ },
58
+ required: ["token"],
59
+ },
60
+ },
61
+ async execute(args) {
62
+ try {
63
+ await auth.acceptToken(String(args.token ?? ""));
64
+ const claims = decodeJwt(await auth.getAccessToken());
65
+ return okResult({
66
+ ok: true,
67
+ tenantId: auth.getTenantId(),
68
+ account: claims?.sub,
69
+ tokenExpiresAt: claims?.exp ? new Date(claims.exp * 1000).toISOString() : undefined,
70
+ });
71
+ }
72
+ catch (err) {
73
+ return failResult(err);
74
+ }
75
+ },
76
+ };
77
+ }
78
+ function createTestTask(client, config) {
79
+ return {
80
+ definition: {
81
+ name: "hubex_create_test_task",
82
+ description: `Создать ТЕСТОВУЮ заявку в HubEx с разумными дефолтами. К тексту заявки автоматически ` +
83
+ `добавляется префикс "${TEST_MARKER}", чтобы её было легко найти и удалить. (изменяет данные)`,
84
+ inputSchema: {
85
+ type: "object",
86
+ properties: {
87
+ notes: { type: "string", description: "Текст/описание заявки." },
88
+ taskTypeID: {
89
+ type: "integer",
90
+ description: "ID типа заявки (список: hubex_request_read с endpointId WORK:GET:/TaskTypes).",
91
+ },
92
+ workTypeID: {
93
+ type: "integer",
94
+ description: "ID вида работ (список: hubex_request_read с endpointId WORK:GET:/WorkTypes).",
95
+ },
96
+ assetID: { type: "integer", description: "ID объекта, к которому относится заявка." },
97
+ companyID: { type: "integer", description: "ID компании-заказчика." },
98
+ criticalityID: { type: "integer", description: "ID критичности." },
99
+ contactPerson: { type: "string", description: "Контактное лицо." },
100
+ contactPhone: { type: "string", description: "Контактный телефон." },
101
+ requestMethodID: {
102
+ type: "integer",
103
+ description: "ID способа подачи (список: hubex_request_read с endpointId WORK:GET:/RequestMethods). " +
104
+ "По умолчанию 1 (WEB), если не настроен HUBEX_DEFAULT_REQUEST_METHOD_ID.",
105
+ },
106
+ },
107
+ required: ["notes"],
108
+ },
109
+ },
110
+ async execute(args) {
111
+ try {
112
+ const notes = String(args.notes ?? "").trim();
113
+ const body = {
114
+ notes: `${TEST_MARKER} ${notes}`.trim(),
115
+ // Приоритет: явный аргумент модели > серверный дефолт (HUBEX_DEFAULT_*) > встроенный дефолт.
116
+ requestMethodID: args.requestMethodID ?? config.defaults.requestMethodID ?? 1,
117
+ };
118
+ const taskTypeID = args.taskTypeID ?? config.defaults.taskTypeID;
119
+ if (taskTypeID !== undefined)
120
+ body.taskTypeID = taskTypeID;
121
+ const companyID = args.companyID ?? config.defaults.companyID;
122
+ if (companyID !== undefined)
123
+ body.companyID = companyID;
124
+ for (const key of [
125
+ "workTypeID",
126
+ "assetID",
127
+ "criticalityID",
128
+ "contactPerson",
129
+ "contactPhone",
130
+ ]) {
131
+ if (args[key] !== undefined)
132
+ body[key] = args[key];
133
+ }
134
+ const res = await client.request({ service: "WORK", method: "POST", path: "/Tasks", body });
135
+ return okResult(res.data);
136
+ }
137
+ catch (err) {
138
+ return failResult(err);
139
+ }
140
+ },
141
+ };
142
+ }
143
+ export function buildCuratedTools(client, config, auth) {
144
+ const tools = [whoami(config, auth)];
145
+ if (config.authMode === "token")
146
+ tools.push(setToken(auth));
147
+ if (config.methods.includes("POST") && (!config.services || config.services.includes("WORK"))) {
148
+ tools.push(createTestTask(client, config));
149
+ }
150
+ return tools;
151
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Ступени лестницы подгрузки: карта скоупов, поиск по методам, описание схемы.
3
+ * Поиск разделён по группам HTTP-методов, чтобы группу можно было выключить
4
+ * настройкой сервера или тумблером в MCP-клиенте.
5
+ */
6
+ import { DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT } from "../index/store.js";
7
+ import { SERVICE_GUIDES } from "../guides/service-map.js";
8
+ import { okResult, failResult } from "./registry.js";
9
+ export const METHOD_GROUPS = {
10
+ read: ["GET"],
11
+ write: ["POST", "PUT", "PATCH"],
12
+ delete: ["DELETE"],
13
+ head: ["HEAD"],
14
+ };
15
+ const GROUP_LABELS = {
16
+ read: "чтения (GET)",
17
+ write: "создания и изменения (POST/PUT/PATCH) — изменяет данные",
18
+ delete: "удаления (DELETE) — удаляет данные",
19
+ head: "проверки существования (HEAD)",
20
+ };
21
+ export const MAX_DESCRIBE_IDS = 5;
22
+ /** Группы, у которых хотя бы один метод разрешён конфигом. */
23
+ export function enabledGroups(methods) {
24
+ const allowed = new Set(methods);
25
+ return Object.keys(METHOD_GROUPS).filter((g) => METHOD_GROUPS[g].some((m) => allowed.has(m)));
26
+ }
27
+ export function formatSearchResults(result) {
28
+ if (result.total === 0) {
29
+ return "Ничего не найдено. Попробуй другой query, убери tag или вызови hubex_list_scopes, чтобы увидеть доступные сервисы.";
30
+ }
31
+ const lines = result.results.map((e) => {
32
+ const parts = [];
33
+ if (e.pathParams.length > 0)
34
+ parts.push(`path: ${e.pathParams.join(", ")}`);
35
+ if (e.queryParams.length > 0)
36
+ parts.push(`query: ${e.queryParams.join(", ")}`);
37
+ if (e.hasBody)
38
+ parts.push(e.bodyRequired ? "body: обязателен" : "body: опционален");
39
+ const suffix = parts.length > 0 ? ` [${parts.join("; ")}]` : "";
40
+ return `${e.id} — ${e.summary}${suffix}`;
41
+ });
42
+ if (result.results.length < result.total) {
43
+ lines.push(`— показано ${result.results.length} из ${result.total}; уточни query или tag, либо увеличь limit (максимум ${MAX_SEARCH_LIMIT}).`);
44
+ }
45
+ return lines.join("\n");
46
+ }
47
+ function listScopes(deps) {
48
+ return {
49
+ definition: {
50
+ name: "hubex_list_scopes",
51
+ description: "Карта доступных областей HubEx. Без аргументов — список сервисов с числом эндпоинтов по методам. " +
52
+ "С аргументом service — ресурсы (теги) внутри сервиса. Первый шаг перед hubex_search_*_endpoints.",
53
+ inputSchema: {
54
+ type: "object",
55
+ properties: {
56
+ service: {
57
+ type: "string",
58
+ description: "Код сервиса, например WORK. Опционально: без него вернётся список сервисов.",
59
+ },
60
+ },
61
+ },
62
+ },
63
+ async execute(args) {
64
+ try {
65
+ const service = args.service === undefined ? undefined : String(args.service);
66
+ if (!service) {
67
+ const lines = deps.index.listServices().map((s) => {
68
+ const counts = Object.entries(s.counts)
69
+ .map(([m, n]) => `${m} ${n}`)
70
+ .join(", ");
71
+ const guide = SERVICE_GUIDES[s.code];
72
+ return (`${s.code} — ${s.title} [${counts}]` +
73
+ (guide ? ` (гайд: hubex_get_guide topic="${guide}")` : ""));
74
+ });
75
+ lines.push("— дальше: hubex_list_scopes с service=<КОД> для списка ресурсов, либо hubex_search_*_endpoints.");
76
+ return okResult(lines.join("\n"));
77
+ }
78
+ const lines = deps.index.listTags(service).map((t) => {
79
+ const counts = Object.entries(t.counts)
80
+ .map(([m, n]) => `${m} ${n}`)
81
+ .join(", ");
82
+ return `${t.tag} [${counts}]`;
83
+ });
84
+ lines.push(`— дальше: hubex_search_*_endpoints со scope="${service.toUpperCase()}" и tag=<ресурс>.`);
85
+ return okResult(lines.join("\n"));
86
+ }
87
+ catch (err) {
88
+ return failResult(err);
89
+ }
90
+ },
91
+ };
92
+ }
93
+ function searchTool(group, deps) {
94
+ return {
95
+ definition: {
96
+ name: `hubex_search_${group}_endpoints`,
97
+ description: `Найти эндпоинты HubEx для ${GROUP_LABELS[group]}. ` +
98
+ `Возвращает только сигнатуры (id, краткое описание, параметры) — схемы полей достаёт hubex_describe_endpoint. ` +
99
+ `Запрос можно писать по-русски (по описанию) или по-английски (по пути и ресурсу).`,
100
+ inputSchema: {
101
+ type: "object",
102
+ properties: {
103
+ query: {
104
+ type: "string",
105
+ description: "Слова для поиска по пути, ресурсу и описанию. Все слова должны найтись.",
106
+ },
107
+ scope: { type: "string", description: "Код сервиса из hubex_list_scopes, например WORK." },
108
+ tag: { type: "string", description: "Ресурс внутри сервиса, например Tasks." },
109
+ limit: {
110
+ type: "integer",
111
+ description: `Сколько результатов вернуть. По умолчанию ${DEFAULT_SEARCH_LIMIT}, максимум ${MAX_SEARCH_LIMIT}.`,
112
+ },
113
+ },
114
+ },
115
+ },
116
+ async execute(args) {
117
+ try {
118
+ const result = deps.index.search({
119
+ methods: METHOD_GROUPS[group],
120
+ query: args.query === undefined ? undefined : String(args.query),
121
+ scope: args.scope === undefined ? undefined : String(args.scope),
122
+ tag: args.tag === undefined ? undefined : String(args.tag),
123
+ limit: args.limit === undefined ? undefined : Number(args.limit),
124
+ });
125
+ return okResult(formatSearchResults(result));
126
+ }
127
+ catch (err) {
128
+ return failResult(err);
129
+ }
130
+ },
131
+ };
132
+ }
133
+ function describeEndpoint(deps) {
134
+ return {
135
+ definition: {
136
+ name: "hubex_describe_endpoint",
137
+ description: "Полная схема параметров и тела для конкретных эндпоинтов. Идентификаторы берутся из hubex_search_*_endpoints " +
138
+ "или из гайда. Вызывай только для тех эндпоинтов, которые собираешься выполнить: схемы объёмные.",
139
+ inputSchema: {
140
+ type: "object",
141
+ properties: {
142
+ endpointIds: {
143
+ type: "array",
144
+ items: { type: "string" },
145
+ description: `Идентификаторы вида SERVICE:METHOD:/path. Не более ${MAX_DESCRIBE_IDS} за вызов.`,
146
+ },
147
+ },
148
+ required: ["endpointIds"],
149
+ },
150
+ },
151
+ async execute(args) {
152
+ try {
153
+ const ids = Array.isArray(args.endpointIds) ? args.endpointIds.map(String) : [];
154
+ if (ids.length === 0) {
155
+ throw new Error("Передай хотя бы один endpointId (см. hubex_search_*_endpoints).");
156
+ }
157
+ if (ids.length > MAX_DESCRIBE_IDS) {
158
+ throw new Error(`Слишком много идентификаторов: не более ${MAX_DESCRIBE_IDS} за вызов, получено ${ids.length}. Разбей на несколько вызовов.`);
159
+ }
160
+ const out = [];
161
+ for (const id of ids) {
162
+ const entry = deps.index.get(id);
163
+ if (!entry) {
164
+ throw new Error(`Эндпоинт ${id} не найден. Вызови hubex_search_*_endpoints со scope, чтобы получить корректный идентификатор.`);
165
+ }
166
+ if (!deps.index.isAllowed(entry)) {
167
+ throw new Error(`Эндпоинт ${id} недоступен: метод ${entry.method} или сервис ${entry.service} отключён ` +
168
+ `настройками HUBEX_METHODS / HUBEX_SERVICES. Вызови hubex_search_*_endpoints, чтобы найти доступный эндпоинт.`);
169
+ }
170
+ const described = deps.schemas.describe(entry);
171
+ out.push(deps.annotate ? deps.annotate(described) : described);
172
+ }
173
+ return okResult(out);
174
+ }
175
+ catch (err) {
176
+ return failResult(err);
177
+ }
178
+ },
179
+ };
180
+ }
181
+ export function buildDiscoveryTools(deps) {
182
+ const tools = [listScopes(deps), describeEndpoint(deps)];
183
+ for (const group of enabledGroups(deps.methods)) {
184
+ tools.push(searchTool(group, deps));
185
+ }
186
+ return tools;
187
+ }