@hubex/mcp 0.9.20261009 → 0.10.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.
package/.env.example CHANGED
@@ -10,6 +10,7 @@ HUBEX_ENV=dev
10
10
  HUBEX_APPLICATION_ID=5
11
11
 
12
12
  # Optional: pin the tenant explicitly. If omitted, it is read from the JWT (TenantID claim).
13
+ # In "login" mode it picks the tenant to authorize in.
13
14
  # HUBEX_TENANT_ID=5
14
15
 
15
16
  # Auth mode: token | login | service
@@ -23,6 +24,11 @@ HUBEX_AUTH_MODE=token
23
24
  # HUBEX_AUTH_MODE=login
24
25
  # HUBEX_USERNAME=poweruser@hubex.ru
25
26
  # HUBEX_PASSWORD=your-password
27
+ # Если учётная запись может войти от имени нескольких пользователей (в разных
28
+ # тенантах или в одном), выбери нужного через HUBEX_TENANT_ID и HUBEX_USER_ID
29
+ # (userID пользователя в тенанте). Если они не сужают выбор до одного, сервер
30
+ # спросит при первом обращении (список в клиенте, либо инструмент hubex_select_tenant).
31
+ # HUBEX_USER_ID=868
26
32
 
27
33
  # --- mode "service" ---
28
34
  # Долгоживущий интеграционный токен. HUBEX_TENANT_ID в этом режиме не нужен:
package/CONNECTING.md CHANGED
@@ -79,7 +79,8 @@ npm run build
79
79
  | `HUBEX_AUTH_MODE` | да | `token`, `login` или `service` |
80
80
  | `HUBEX_USERNAME` / `HUBEX_PASSWORD` | для `login` | логин/пароль (токен обновляется сам) |
81
81
  | `HUBEX_SERVICE_TOKEN` | для `service` | долгоживущий интеграционный токен (обновляется сам) |
82
- | `HUBEX_TENANT_ID` | нет | tenant явно; иначе берётся из JWT |
82
+ | `HUBEX_TENANT_ID` | нет | tenant явно; иначе берётся из JWT. В `login` — в каком тенанте авторизоваться |
83
+ | `HUBEX_USER_ID` | нет | в `login` — от имени какого пользователя (userID) войти, если в тенанте их несколько |
83
84
  | `HUBEX_METHODS` | нет | список доступных HTTP-методов через запятую (по умолчанию — все шесть) |
84
85
  | `HUBEX_READONLY` | нет | `true` — эквивалент `HUBEX_METHODS=GET,HEAD` |
85
86
  | `HUBEX_SERVICES` | нет | ограничить поиск и исполнение списком сервисов (по умолчанию — все 22) |
@@ -95,6 +96,13 @@ npm run build
95
96
  > Если HubEx отверг токен (ошибка 401), следующий запрос получит новый — по
96
97
  > refresh, а если не выйдет, сервер снова попросит токен.
97
98
 
99
+ > В режиме `login` учётная запись может входить от имени нескольких
100
+ > пользователей — в разных тенантах или в одном. Если `HUBEX_TENANT_ID` и
101
+ > `HUBEX_USER_ID` не сужают выбор до одного, при первом обращении сервер
102
+ > покажет список (клиенты с elicitation) или попросит вызвать инструмент
103
+ > `hubex_select_tenant` с выбранными `tenantID` и `userID`. Выбор действует до
104
+ > перезапуска сервера.
105
+
98
106
  > Сервисный токен выпускается один раз администратором:
99
107
  > `POST /fsm/AUTHZ/ServiceTokens` с Bearer и полномочием `ServiceTokenAdd`, тело — массив id
100
108
  > членов тенанта. Отзыв — `DELETE /fsm/AUTHZ/ServiceTokens` с полномочием `ServiceTokenRemove`.
package/README.md CHANGED
@@ -151,7 +151,8 @@ claude mcp add hubex --scope user \
151
151
  |---|---|---|
152
152
  | `HUBEX_ENV` | `dev` | Окружение: `dev` \| `stg` \| `prod`. От него зависят базовый URL и то, какой кэш swagger-схем берётся (`prod` или `dev`) |
153
153
  | `HUBEX_APPLICATION_ID` | — (**обязательный**) | Значение заголовка `X-Application-ID` (`5` — Api/интеграции, `3` — MainWeb) |
154
- | `HUBEX_TENANT_ID` | из JWT (`TenantID`) | Задаёт тенант явно. В режиме `service` не нужен |
154
+ | `HUBEX_TENANT_ID` | из JWT (`TenantID`) | Задаёт тенант явно. В режиме `login` — в каком тенанте авторизоваться; если не задан и тенантов несколько, сервер спросит. В режиме `service` не нужен |
155
+ | `HUBEX_USER_ID` | — | Только для `login`: userID, от имени которого войти, если пользователей несколько. Если не задан, сервер спросит |
155
156
  | `HUBEX_AUTH_MODE` | `token` | Режим авторизации: `token` \| `login` \| `service` |
156
157
  | `HUBEX_USERNAME` | — | Логин. Обязателен при `login` |
157
158
  | `HUBEX_PASSWORD` | — | Пароль. Обязателен при `login` |
@@ -177,7 +178,7 @@ claude mcp add hubex --scope user \
177
178
  | Настройка | Что делает | По умолчанию |
178
179
  |---|---|---|
179
180
  | `HUBEX_ENV` | Контур: `dev` — тестовый, `stg` — предбоевой, `prod` — боевой | `dev` |
180
- | `HUBEX_TENANT_ID` | Номер тенанта. Обычно не нужен: сервер сам берёт его из вашего токена. Пригодится, только если у вас доступ сразу к нескольким тенантам | берётся из токена |
181
+ | `HUBEX_TENANT_ID` | Номер тенанта. Обычно не нужен: сервер сам берёт его из вашего токена. Если вы входите по логину и паролю и у вас несколько тенантов, сервер спросит, в какой войти, — переменная избавляет от вопроса | берётся из токена |
181
182
 
182
183
  ### Как входить
183
184
 
@@ -186,6 +187,7 @@ claude mcp add hubex --scope user \
186
187
  | `HUBEX_AUTH_MODE` | Способ входа: `token` — сервер спросит токен при первом обращении, `login` — вход по логину и паролю, `service` — по долгоживущему служебному токену | `token` |
187
188
  | `HUBEX_USERNAME` | Логин. Нужен только при `HUBEX_AUTH_MODE=login` | — |
188
189
  | `HUBEX_PASSWORD` | Пароль. Нужен только при `HUBEX_AUTH_MODE=login` | — |
190
+ | `HUBEX_USER_ID` | Номер пользователя, от имени которого входить по логину и паролю, если в тенанте их у вас несколько. Если не задан, сервер спросит | — |
189
191
  | `HUBEX_SERVICE_TOKEN` | Служебный токен, который выдаёт администратор HubEx. Нужен только при `HUBEX_AUTH_MODE=service`. Удобен, когда сервер работает сам по себе и спросить токен не у кого | — |
190
192
 
191
193
  ### Что помощнику разрешено делать
@@ -206,7 +208,7 @@ claude mcp add hubex --scope user \
206
208
  | Настройка | Что делает |
207
209
  |---|---|
208
210
  | `HUBEX_DEFAULT_COMPANY_ID` | Компания по умолчанию |
209
- | `HUBEX_DEFAULT_TASK_TYPE_ID` | Тип заявки по умолчанию |
211
+ | `HUBEX_DEFAULT_TASK_TYPE_ID` | Тип заявки по умолчанию. Если не задан, перед созданием заявки ассистент спросит тип у вас |
210
212
  | `HUBEX_DEFAULT_REQUEST_METHOD_ID` | Способ обращения по умолчанию (например, «звонок» или «почта») |
211
213
 
212
214
  ### Пример: строгая настройка для боевого контура
package/dist/auth.js CHANGED
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Authentication. One lifecycle, three ways to obtain the first access token:
3
3
  * - "token": the user is asked for a ready Bearer JWT.
4
- * - "login": Basic (username:password) -> JWT via the AUTHN service.
4
+ * - "login": Basic (username:password) via AUTHN, then a tenant user JWT via
5
+ * AUTHZ/Accounts/authorize.
5
6
  * - "service": a long-lived service token -> JWT via AUTHZ/AccessTokens.
6
7
  * Afterwards all three refresh the same way.
7
8
  */
9
+ export const SELECT_TENANT_TOOL = "hubex_select_tenant";
8
10
  /** Decode a JWT payload without verifying the signature (we only read claims). */
9
11
  export function decodeJwt(token) {
10
12
  const parts = token.split(".");
@@ -41,6 +43,16 @@ export function assertUsableJwt(token) {
41
43
  throw new Error("Срок действия токена уже истёк. Нужен свежий access-токен HubEx.");
42
44
  }
43
45
  }
46
+ /**
47
+ * Lists tenant users for the model to relay. Person names stay out: this text
48
+ * reaches the LLM context, which only gets PII through the masking layer.
49
+ */
50
+ function describeMembers(members) {
51
+ return members
52
+ .map((m) => `тенант ${m.tenantID}${m.name ? ` «${m.name}»` : ""}, пользователь ${m.userID}` +
53
+ (m.tenantMemberDescription ? ` (${m.tenantMemberDescription})` : ""))
54
+ .join("; ");
55
+ }
44
56
  /**
45
57
  * Obtains a JWT and keeps it fresh. Three ways in — Basic credentials
46
58
  * ("login"), a long-lived service token ("service") or a token typed in by the
@@ -49,6 +61,9 @@ export function assertUsableJwt(token) {
49
61
  class RefreshingAuth {
50
62
  config;
51
63
  prompter;
64
+ chooser;
65
+ /** Tenant user picked by the user; survives re-logins so they are asked once. */
66
+ chosen;
52
67
  accessToken;
53
68
  accessExp; // seconds since epoch
54
69
  /**
@@ -70,6 +85,28 @@ class RefreshingAuth {
70
85
  setPrompter(prompt) {
71
86
  this.prompter = prompt;
72
87
  }
88
+ setTenantChooser(choose) {
89
+ this.chooser = choose;
90
+ }
91
+ async selectTenantMember(tenantID, userID) {
92
+ if (this.inflight)
93
+ await this.inflight.catch(() => undefined);
94
+ const previous = this.chosen;
95
+ this.chosen = { tenantID, userID };
96
+ // Tokens of the previous user must not be reused or refreshed.
97
+ this.accessToken = undefined;
98
+ this.accessExp = undefined;
99
+ this.refreshToken = undefined;
100
+ this.refreshExp = undefined;
101
+ try {
102
+ await this.getAccessToken();
103
+ }
104
+ catch (err) {
105
+ // A wrong pick must not lock out the next login.
106
+ this.chosen = previous;
107
+ throw err;
108
+ }
109
+ }
73
110
  /** Stores a token supplied out of band and immediately buys a refresh token. */
74
111
  async acceptToken(token) {
75
112
  const trimmed = token.trim();
@@ -167,13 +204,65 @@ class RefreshingAuth {
167
204
  assertUsableJwt(token);
168
205
  return token;
169
206
  }
170
- loginByPassword() {
207
+ /**
208
+ * Two steps, as in the HubEx web and mobile apps. AUTHN only proves the
209
+ * credentials: its token carries no tenant, so every service answers 401 and
210
+ * AUTHZ/RefreshTokens 403. AUTHZ/Accounts/authorize trades it for a token of
211
+ * one tenant member, picked from the tenants the login reported.
212
+ */
213
+ async loginByPassword() {
171
214
  const basic = Buffer.from(`${encodeURIComponent(this.config.username)}:${encodeURIComponent(this.config.password)}`).toString("base64");
172
- return this.fetchJwt(`${this.config.apiBaseUrl}/AUTHN/Accounts/login`, {
215
+ const account = (await this.fetchJwt(`${this.config.apiBaseUrl}/AUTHN/Accounts/login`, { method: "POST", headers: this.authHeaders({ Authorization: `Basic ${basic}` }) }, `Проверь HUBEX_USERNAME и HUBEX_PASSWORD: учётная запись должна существовать в окружении ` +
216
+ `HUBEX_ENV=${this.config.env}.`));
217
+ const member = await this.pickTenantMember(account.tenantEntities ?? []);
218
+ // The chosen tenant, even when switching users: absorb() keeps a known tenant.
219
+ this.tenantId = String(member.tenantID);
220
+ return this.fetchJwt(`${this.config.apiBaseUrl}/AUTHZ/Accounts/authorize`, {
173
221
  method: "POST",
174
- headers: this.authHeaders({ Authorization: `Basic ${basic}` }),
222
+ headers: this.authHeaders({
223
+ Authorization: `Bearer ${account.access_token}`,
224
+ "Content-Type": "application/json",
225
+ }),
226
+ body: JSON.stringify({ tenantID: member.tenantID, tenantMemberID: member.tenantMemberID }),
175
227
  });
176
228
  }
229
+ /**
230
+ * Narrows the account's tenant users by HUBEX_TENANT_ID / HUBEX_USER_ID. One
231
+ * left — sign in as it; several — the user decides, once.
232
+ */
233
+ async pickTenantMember(members) {
234
+ if (members.length === 0) {
235
+ throw new Error("Учётная запись не состоит ни в одном тенанте — авторизоваться не в чем.");
236
+ }
237
+ const { tenantId, userId } = this.config;
238
+ const candidates = members.filter((m) => (!tenantId || String(m.tenantID) === tenantId) && (!userId || String(m.userID) === userId));
239
+ if (candidates.length === 0) {
240
+ const filter = [tenantId && `HUBEX_TENANT_ID=${tenantId}`, userId && `HUBEX_USER_ID=${userId}`]
241
+ .filter(Boolean)
242
+ .join(", ");
243
+ throw new Error(`Нет пользователя с ${filter}. Учётной записи доступны: ${describeMembers(members)}.`);
244
+ }
245
+ const chosen = this.chosen;
246
+ if (chosen) {
247
+ const match = candidates.find((m) => m.tenantID === chosen.tenantID && m.userID === chosen.userID);
248
+ if (!match) {
249
+ throw new Error(`Нет пользователя ${chosen.userID} в тенанте ${chosen.tenantID}. ` +
250
+ `Доступны: ${describeMembers(candidates)}.`);
251
+ }
252
+ return match;
253
+ }
254
+ if (candidates.length === 1)
255
+ return candidates[0];
256
+ const picked = this.chooser ? await this.chooser(candidates) : undefined;
257
+ if (!picked) {
258
+ throw new Error(`Учётная запись может войти от имени нескольких пользователей HubEx. Спроси пользователя, ` +
259
+ `под кем работать, и вызови ${SELECT_TENANT_TOOL} с его tenantID и userID ` +
260
+ `(или задай HUBEX_TENANT_ID и HUBEX_USER_ID в настройках сервера). ` +
261
+ `Варианты: ${describeMembers(candidates)}.`);
262
+ }
263
+ this.chosen = { tenantID: picked.tenantID, userID: picked.userID };
264
+ return picked;
265
+ }
177
266
  /** AUTHZ/AccessTokens is anonymous — the service token itself identifies the member. */
178
267
  loginByServiceToken() {
179
268
  return this.fetchJwt(`${this.config.apiBaseUrl}/AUTHZ/AccessTokens`, {
@@ -213,12 +302,16 @@ class RefreshingAuth {
213
302
  ...extra,
214
303
  };
215
304
  }
216
- async fetchJwt(url, init) {
305
+ /** `rejectHint` replaces the default wrong-environment hint on 401/403/409. */
306
+ async fetchJwt(url, init, rejectHint) {
217
307
  const res = await fetch(url, init);
218
308
  if (!res.ok) {
219
309
  const body = await res.text().catch(() => "");
220
310
  let message = `${init.method ?? "GET"} ${url} failed (HTTP ${res.status}): ${body.slice(0, 500)}`;
221
- if (res.status === 401 || res.status === 403) {
311
+ if (rejectHint && [401, 403, 409].includes(res.status)) {
312
+ message += ` ${rejectHint}`;
313
+ }
314
+ else if (res.status === 401 || res.status === 403) {
222
315
  message +=
223
316
  ` Сейчас выбрано окружение HUBEX_ENV=${this.config.env}. Если токен получен в другом ` +
224
317
  `окружении (dev/stg/prod), авторизация не пройдёт — проверь, что токен именно из ${this.config.env}.`;
package/dist/config.js CHANGED
@@ -93,6 +93,7 @@ export function loadConfig(env = process.env) {
93
93
  swaggerCatalog: swaggerCatalogFor(hubexEnv),
94
94
  applicationId: required("HUBEX_APPLICATION_ID", env.HUBEX_APPLICATION_ID),
95
95
  tenantId: env.HUBEX_TENANT_ID?.trim() || undefined,
96
+ userId: env.HUBEX_USER_ID?.trim() || undefined,
96
97
  authMode: authMode,
97
98
  readonly: parseBool(env.HUBEX_READONLY),
98
99
  maskPii: parseBoolDefaultTrue(env.HUBEX_MASK_PII),
package/dist/server.js CHANGED
@@ -7,6 +7,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
7
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
8
8
  import { createAuthProvider } from "./auth.js";
9
9
  import { createElicitPrompter } from "./token-prompt.js";
10
+ import { createElicitTenantChooser } from "./tenant-prompt.js";
10
11
  import { HubexClient } from "./http.js";
11
12
  import { EndpointIndex, loadIndexFile } from "./index/store.js";
12
13
  import { SchemaProvider } from "./schema/describe.js";
@@ -51,6 +52,8 @@ export function createServer(config) {
51
52
  // Провайдер создаётся раньше сервера, поэтому способ ввода вносится сюда, а не в конструктор.
52
53
  if (config.authMode === "token")
53
54
  auth.setPrompter(createElicitPrompter(server));
55
+ if (config.authMode === "login")
56
+ auth.setTenantChooser(createElicitTenantChooser(server));
54
57
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
55
58
  tools: [...registry.values()].map((t) => ({
56
59
  name: t.definition.name,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Asking the user which tenant user to sign in as in `HUBEX_AUTH_MODE=login`,
3
+ * when the account has several and HUBEX_TENANT_ID / HUBEX_USER_ID do not
4
+ * narrow them to one.
5
+ *
6
+ * The primary channel is MCP elicitation: the client shows a pick list and the
7
+ * people's names in it never pass through the model's context. Clients without
8
+ * that capability fall back to the `hubex_select_tenant` tool.
9
+ */
10
+ import { SELECT_TENANT_TOOL } from "./auth.js";
11
+ const PROMPT_MESSAGE = "Учётная запись HubEx может войти от имени нескольких пользователей. Выбери, под кем работать — " +
12
+ "выбор сохранится до перезапуска сервера.";
13
+ function optionValue(m) {
14
+ return `${m.tenantID}:${m.userID}`;
15
+ }
16
+ function optionLabel(m) {
17
+ const person = [m.lastName, m.firstName].filter(Boolean).join(" ");
18
+ const details = [person, m.tenantMemberDescription].filter(Boolean).join(", ");
19
+ return `${m.name ?? `Тенант ${m.tenantID}`}${details ? ` — ${details}` : ""} ` +
20
+ `(тенант ${m.tenantID}, пользователь ${m.userID})`;
21
+ }
22
+ export function createElicitTenantChooser(server) {
23
+ return async (options) => {
24
+ if (!server.getClientCapabilities()?.elicitation)
25
+ return undefined;
26
+ const result = await server.elicitInput({
27
+ message: PROMPT_MESSAGE,
28
+ requestedSchema: {
29
+ type: "object",
30
+ properties: {
31
+ member: {
32
+ type: "string",
33
+ title: "Пользователь HubEx",
34
+ enum: options.map(optionValue),
35
+ enumNames: options.map(optionLabel),
36
+ },
37
+ },
38
+ required: ["member"],
39
+ },
40
+ });
41
+ if (result.action !== "accept") {
42
+ throw new Error("Выбор пользователя HubEx отменён — авторизоваться не удалось. Если клиент заявляет " +
43
+ "поддержку elicitation, но не показывает форму, спроси пользователя, под кем работать, " +
44
+ `и вызови ${SELECT_TENANT_TOOL} с его tenantID и userID.`);
45
+ }
46
+ const picked = options.find((m) => optionValue(m) === result.content?.member);
47
+ if (!picked)
48
+ throw new Error("Пользователь HubEx не выбран.");
49
+ return picked;
50
+ };
51
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Hand-written convenience tools layered on top of the generated ones.
3
3
  */
4
- import { decodeJwt } from "../auth.js";
4
+ import { SELECT_TENANT_TOOL, decodeJwt } from "../auth.js";
5
5
  import { SET_TOKEN_TOOL } from "../token-prompt.js";
6
6
  import { okResult, failResult } from "./registry.js";
7
7
  /** Marker prepended to test data so it is easy to find and clean up later. */
@@ -75,7 +75,49 @@ function setToken(auth) {
75
75
  },
76
76
  };
77
77
  }
78
+ /**
79
+ * Fallback for clients without elicitation support: the model asks the user
80
+ * which tenant user to sign in as and passes the answer here.
81
+ */
82
+ function selectTenant(auth) {
83
+ return {
84
+ definition: {
85
+ name: SELECT_TENANT_TOOL,
86
+ description: "Выбрать, от имени какого пользователя HubEx работать, если учётная запись может войти от " +
87
+ "нескольких (в разных тенантах или в одном). Нужен, если клиент не умеет показывать выбор " +
88
+ "сам: варианты придут в ошибке авторизации — спроси пользователя и передай его выбор сюда. " +
89
+ "Выбор действует до перезапуска сервера.",
90
+ inputSchema: {
91
+ type: "object",
92
+ properties: {
93
+ tenantID: { type: "integer", description: "ID тенанта." },
94
+ userID: { type: "integer", description: "ID пользователя в этом тенанте." },
95
+ },
96
+ required: ["tenantID", "userID"],
97
+ },
98
+ },
99
+ async execute(args) {
100
+ try {
101
+ await auth.selectTenantMember(Number(args.tenantID), Number(args.userID));
102
+ const claims = decodeJwt(await auth.getAccessToken());
103
+ return okResult({
104
+ ok: true,
105
+ tenantId: auth.getTenantId(),
106
+ tenantMemberId: claims?.TenantMemberID,
107
+ account: claims?.sub,
108
+ });
109
+ }
110
+ catch (err) {
111
+ return failResult(err);
112
+ }
113
+ },
114
+ };
115
+ }
116
+ // Типы заявок у каждого тенанта свои: без серверного дефолта модель не должна выбирать тип сама.
117
+ const ASK_TASK_TYPE = "Спроси пользователя, какой тип заявки использовать: покажи ему список из " +
118
+ "hubex_request_read с endpointId WORK:GET:/TaskTypes и не выбирай тип сам.";
78
119
  function createTestTask(client, config) {
120
+ const defaultTaskTypeID = config.defaults.taskTypeID;
79
121
  return {
80
122
  definition: {
81
123
  name: "hubex_create_test_task",
@@ -87,7 +129,10 @@ function createTestTask(client, config) {
87
129
  notes: { type: "string", description: "Текст/описание заявки." },
88
130
  taskTypeID: {
89
131
  type: "integer",
90
- description: "ID типа заявки (список: hubex_request_read с endpointId WORK:GET:/TaskTypes).",
132
+ description: defaultTaskTypeID !== undefined
133
+ ? "ID типа заявки (список: hubex_request_read с endpointId WORK:GET:/TaskTypes). " +
134
+ `По умолчанию ${defaultTaskTypeID} (HUBEX_DEFAULT_TASK_TYPE_ID).`
135
+ : `ID типа заявки. Обязателен: дефолт HUBEX_DEFAULT_TASK_TYPE_ID не задан. ${ASK_TASK_TYPE}`,
91
136
  },
92
137
  workTypeID: {
93
138
  type: "integer",
@@ -104,20 +149,22 @@ function createTestTask(client, config) {
104
149
  "По умолчанию 1 (WEB), если не настроен HUBEX_DEFAULT_REQUEST_METHOD_ID.",
105
150
  },
106
151
  },
107
- required: ["notes"],
152
+ required: defaultTaskTypeID !== undefined ? ["notes"] : ["notes", "taskTypeID"],
108
153
  },
109
154
  },
110
155
  async execute(args) {
111
156
  try {
157
+ const taskTypeID = args.taskTypeID ?? defaultTaskTypeID;
158
+ if (taskTypeID === undefined) {
159
+ return failResult(new Error(`Не указан taskTypeID, а HUBEX_DEFAULT_TASK_TYPE_ID не задан. ${ASK_TASK_TYPE}`));
160
+ }
112
161
  const notes = String(args.notes ?? "").trim();
113
162
  const body = {
114
163
  notes: `${TEST_MARKER} ${notes}`.trim(),
115
164
  // Приоритет: явный аргумент модели > серверный дефолт (HUBEX_DEFAULT_*) > встроенный дефолт.
116
165
  requestMethodID: args.requestMethodID ?? config.defaults.requestMethodID ?? 1,
166
+ taskTypeID,
117
167
  };
118
- const taskTypeID = args.taskTypeID ?? config.defaults.taskTypeID;
119
- if (taskTypeID !== undefined)
120
- body.taskTypeID = taskTypeID;
121
168
  const companyID = args.companyID ?? config.defaults.companyID;
122
169
  if (companyID !== undefined)
123
170
  body.companyID = companyID;
@@ -144,6 +191,8 @@ export function buildCuratedTools(client, config, auth) {
144
191
  const tools = [whoami(config, auth)];
145
192
  if (config.authMode === "token")
146
193
  tools.push(setToken(auth));
194
+ if (config.authMode === "login")
195
+ tools.push(selectTenant(auth));
147
196
  if (config.methods.includes("POST") && (!config.services || config.services.includes("WORK"))) {
148
197
  tools.push(createTestTask(client, config));
149
198
  }
@@ -65,8 +65,11 @@ sources:
65
65
  Набор полей заявки в каждом тенанте свой. Не собирай тело по этому гайду по памяти
66
66
  и не придумывай значения за пользователя — действуй так:
67
67
 
68
- 1. Возьми `taskTypeID` (если тип не назван — покажи список из `WORK:GET:/TaskTypes`
69
- и спроси, какой нужен; если тип в тенанте один, бери его молча).
68
+ 1. Возьми `taskTypeID`. Если пользователь тип не назвал и он не задан в настройках
69
+ сервера (тогда `taskTypeID` перечислен среди подставляемых полей в описании
70
+ `hubex_request_write`), покажи список из `WORK:GET:/TaskTypes` и спроси, какой
71
+ нужен. Не выбирай тип сам по похожему названию; молча брать можно, только если
72
+ тип в тенанте один.
70
73
  2. Запроси `WORK:GET:/Tasks/new/meta` с query `taskTypeID=<выбранный>` **до** сборки тела.
71
74
  3. Разбери ответ на три группы:
72
75
  - `capability` = `RWM` в `components` → **обязательное поле**, заявка без него
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubex/mcp",
3
- "version": "0.9.20261009",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "MCP server for managing HubEx test data (tasks, assets, companies, users) via the HubEx REST API",
6
6
  "author": "HubEx Team",
@@ -4196,7 +4196,7 @@
4196
4196
  "Roles"
4197
4197
  ],
4198
4198
  "summary": "Получить список расширений роли",
4199
- "description": "## Пример запроса:\r\n\r\nGET /roles/1/packages?searchText=модуль\r\n\r\n## Пример успешного ответа:\r\n```json\r\n{\r\n \"1\": [\r\n {\r\n \"packageID\": \"pkg-10\",\r\n \"packageVersion\": \"1.0.0\",\r\n \"packageName\": \"Модуль отчетности\",\r\n \"isEnabled\": true,\r\n \"resource\": {\r\n \"id\": 1,\r\n \"code\": \"WEB\",\r\n \"name\": \"Web интерфейс\"\r\n }\r\n }\r\n ]\r\n}\r\n```\r\n \r\n## Пример успешного ответа (206):\r\nТело ответа имеет тот же формат, что и для `200`, но содержит частичный диапазон.\r\n## Негативные сценарии:\r\n- 204 NoContent: расширения не найдены.\r\n- 401 Unauthorized: отсутствует или некорректен Bearer-токен.\r\n- 403 Forbidden: недостаточно прав `RolePackageList`.",
4199
+ "description": "## Пример запроса:\r\n\r\nGET /roles/1/packages?searchText=модуль\r\n\r\n## Пример успешного ответа:\r\n```json\r\n{\r\n \"1\": {\r\n \"id\": 1,\r\n \"packageID\": 10,\r\n \"packageVersion\": \"1.0.0\",\r\n \"packageName\": \"Модуль отчетности\",\r\n \"isEnabled\": true,\r\n \"type\": \"iframe\",\r\n \"isCacheable\": true,\r\n \"resource\": {\r\n \"id\": 1,\r\n \"code\": \"WEB\",\r\n \"name\": \"Web интерфейс\"\r\n }\r\n ]\r\n}\r\n```\r\n \r\n## Пример успешного ответа (206):\r\nТело ответа имеет тот же формат, что и для `200`, но содержит частичный диапазон.\r\n## Негативные сценарии:\r\n- 204 NoContent: расширения не найдены.\r\n- 401 Unauthorized: отсутствует или некорректен Bearer-токен.\r\n- 403 Forbidden: недостаточно прав `RolePackageList`.",
4200
4200
  "parameters": [
4201
4201
  {
4202
4202
  "name": "roleID",
@@ -7900,7 +7900,7 @@
7900
7900
  "Tenants"
7901
7901
  ],
7902
7902
  "summary": "Получить список расширений тенанта",
7903
- "description": "## Пример запроса:\r\n\r\nGET /tenants/this/packages?resourceID=1&resourceID=2\r\n\r\n## Пример успешного ответа:\r\n```json\r\n[\r\n {\r\n \"resource\": {\r\n \"id\": 1,\r\n \"name\": \"Web интерфейс\"\r\n },\r\n \"package\": {\r\n \"id\": \"pkg-1\",\r\n \"name\": \"Расширение отчетности\",\r\n \"version\": \"1.0.0\",\r\n \"iconUrl\": \"https://example.com/icon.png\",\r\n \"isAddAuthorizeParameters\": false\r\n }\r\n }\r\n]\r\n```\r\n \r\n## Пример успешного ответа (206):\r\nТело ответа имеет тот же формат, что и для `200`, но содержит частичный диапазон.\r\n## Негативные сценарии:\r\n- 204 NoContent: расширения не найдены.\r\n- 401 Unauthorized: отсутствует или некорректен Bearer-токен.\r\n- 403 Forbidden: недостаточно прав доступа.",
7903
+ "description": "## Пример запроса:\r\n\r\nGET /tenants/this/packages?resourceID=1&resourceID=2\r\n\r\n## Пример успешного ответа:\r\n```json\r\n[\r\n {\r\n \"resource\": {\r\n \"id\": 1,\r\n \"name\": \"Web интерфейс\"\r\n },\r\n \"package\": {\r\n \"id\": \"pkg-1\",\r\n \"name\": \"Расширение отчетности\",\r\n \"version\": \"1.0.0\",\r\n \"iconUrl\": \"https://example.com/icon.png\",\r\n \"type\": \"iframe\",\r\n \"isCacheable\": true\r\n \"isAddAuthorizeParameters\": false\r\n }\r\n }\r\n]\r\n```\r\n \r\n## Пример успешного ответа (206):\r\nТело ответа имеет тот же формат, что и для `200`, но содержит частичный диапазон.\r\n## Негативные сценарии:\r\n- 204 NoContent: расширения не найдены.\r\n- 401 Unauthorized: отсутствует или некорректен Bearer-токен.\r\n- 403 Forbidden: недостаточно прав доступа.",
7904
7904
  "parameters": [
7905
7905
  {
7906
7906
  "name": "resourceID",
@@ -8027,7 +8027,7 @@
8027
8027
  "Tenants"
8028
8028
  ],
8029
8029
  "summary": "Добавить расширение (только для кросс-тенантных администраторов)",
8030
- "description": "## Пример запроса:\r\n\r\nPOST /tenants/this/packages\r\n\r\n```json\r\n{\r\n \"name\": \"Расширение отчетности\",\r\n \"iconUrl\": \"https://example.com/icon.png\",\r\n \"addonUrl\": \"https://example.com/addon\",\r\n \"resourceID\": 1,\r\n \"isMobile\": false\r\n}\r\n```\r\n\r\n## Пример успешного ответа:\r\n```json\r\n[\r\n {\r\n \"resource\": {\r\n \"id\": 1,\r\n \"name\": \"Web интерфейс\"\r\n },\r\n \"package\": {\r\n \"id\": \"pkg-1\",\r\n \"name\": \"Расширение отчетности\",\r\n \"version\": \"1.0.0\",\r\n \"iconUrl\": \"https://example.com/icon.png\",\r\n \"isAddAuthorizeParameters\": false\r\n }\r\n }\r\n]\r\n```\r\n \r\n## Негативные сценарии:\r\n- 400 BadRequest: пустое или отсутствующее тело запроса (`data`).\r\n- 204 NoContent: расширение не найдено.\r\n- 401 Unauthorized: отсутствует или некорректен Bearer-токен.\r\n- 403 Forbidden: недостаточно прав доступа.",
8030
+ "description": "## Пример запроса:\r\n\r\nPOST /tenants/this/packages\r\n\r\n```json\r\n{\r\n \"name\": \"Расширение отчетности\",\r\n \"iconUrl\": \"https://example.com/icon.png\",\r\n \"addonUrl\": \"https://example.com/addon\",\r\n \"resourceID\": 1,\r\n \"type\": \"iframe\",\r\n \"isCacheable\": true\r\n \"isMobile\": false\r\n}\r\n```\r\n\r\n## Пример успешного ответа:\r\n```json\r\n[\r\n {\r\n \"resource\": {\r\n \"id\": 1,\r\n \"name\": \"Web интерфейс\"\r\n },\r\n \"package\": {\r\n \"id\": \"pkg-1\",\r\n \"name\": \"Расширение отчетности\",\r\n \"version\": \"1.0.0\",\r\n \"iconUrl\": \"https://example.com/icon.png\",\r\n \"isAddAuthorizeParameters\": false\r\n }\r\n }\r\n]\r\n```\r\n \r\n## Негативные сценарии:\r\n- 400 BadRequest: пустое или отсутствующее тело запроса (`data`).\r\n- 204 NoContent: расширение не найдено.\r\n- 401 Unauthorized: отсутствует или некорректен Bearer-токен.\r\n- 403 Forbidden: недостаточно прав доступа.",
8031
8031
  "parameters": [
8032
8032
  {
8033
8033
  "name": "X-Application-ID",
@@ -20622,6 +20622,13 @@
20622
20622
  },
20623
20623
  "isMobile": {
20624
20624
  "type": "boolean"
20625
+ },
20626
+ "type": {
20627
+ "type": "string",
20628
+ "nullable": true
20629
+ },
20630
+ "isCacheable": {
20631
+ "type": "boolean"
20625
20632
  }
20626
20633
  },
20627
20634
  "additionalProperties": false
@@ -24007,6 +24014,17 @@
24007
24014
  "type": "boolean",
24008
24015
  "description": "Признак того, что нужно дополнить URL-пакета параметрами для авторизации (powerBI)",
24009
24016
  "example": false
24017
+ },
24018
+ "type": {
24019
+ "type": "string",
24020
+ "description": "Тип плагина",
24021
+ "nullable": true,
24022
+ "example": "iframe"
24023
+ },
24024
+ "isCacheable": {
24025
+ "type": "boolean",
24026
+ "description": "Признак кешируемости плагина",
24027
+ "example": true
24010
24028
  }
24011
24029
  },
24012
24030
  "additionalProperties": false,
@@ -24814,6 +24832,17 @@
24814
24832
  "description": "Флаг что расширение включено у роли",
24815
24833
  "example": true
24816
24834
  },
24835
+ "type": {
24836
+ "type": "string",
24837
+ "description": "Тип плагина",
24838
+ "nullable": true,
24839
+ "example": "iframe"
24840
+ },
24841
+ "isCacheable": {
24842
+ "type": "boolean",
24843
+ "description": "Признак кешируемости плагина",
24844
+ "example": true
24845
+ },
24817
24846
  "resource": {
24818
24847
  "$ref": "#/components/schemas/IdCodeNameResultOfByte"
24819
24848
  }