@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
package/dist/auth.js ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Authentication. One lifecycle, three ways to obtain the first access token:
3
+ * - "token": the user is asked for a ready Bearer JWT.
4
+ * - "login": Basic (username:password) -> JWT via the AUTHN service.
5
+ * - "service": a long-lived service token -> JWT via AUTHZ/AccessTokens.
6
+ * Afterwards all three refresh the same way.
7
+ */
8
+ /** Decode a JWT payload without verifying the signature (we only read claims). */
9
+ export function decodeJwt(token) {
10
+ const parts = token.split(".");
11
+ if (parts.length < 2)
12
+ return undefined;
13
+ try {
14
+ const json = Buffer.from(parts[1], "base64url").toString("utf8");
15
+ return JSON.parse(json);
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ function tenantFromClaims(claims) {
22
+ const t = claims?.TenantID;
23
+ return t === undefined || t === null ? undefined : String(t);
24
+ }
25
+ /** exp is in seconds since epoch; treat as expired 30s early to avoid races. */
26
+ function isExpired(expSeconds) {
27
+ if (!expSeconds)
28
+ return false;
29
+ return Date.now() >= expSeconds * 1000 - 30_000;
30
+ }
31
+ /**
32
+ * Validates a token typed in by the user. Rejecting here — before any request —
33
+ * gives a clear message instead of a bare 401 later.
34
+ */
35
+ export function assertUsableJwt(token) {
36
+ const claims = decodeJwt(token);
37
+ if (!claims) {
38
+ throw new Error("Это не похоже на JWT: ожидается access-токен HubEx без префикса «Bearer ».");
39
+ }
40
+ if (isExpired(claims.exp)) {
41
+ throw new Error("Срок действия токена уже истёк. Нужен свежий access-токен HubEx.");
42
+ }
43
+ }
44
+ /**
45
+ * Obtains a JWT and keeps it fresh. Three ways in — Basic credentials
46
+ * ("login"), a long-lived service token ("service") or a token typed in by the
47
+ * user ("token") — and one shared token lifecycle afterwards.
48
+ */
49
+ class RefreshingAuth {
50
+ config;
51
+ prompter;
52
+ accessToken;
53
+ accessExp; // seconds since epoch
54
+ refreshToken;
55
+ refreshExp; // seconds since epoch
56
+ tenantId;
57
+ inflight;
58
+ constructor(config) {
59
+ this.config = config;
60
+ this.tenantId = config.tenantId;
61
+ }
62
+ getTenantId() {
63
+ return this.tenantId;
64
+ }
65
+ setPrompter(prompt) {
66
+ this.prompter = prompt;
67
+ }
68
+ /** Stores a token supplied out of band and immediately buys a refresh token. */
69
+ async acceptToken(token) {
70
+ const trimmed = token.trim();
71
+ assertUsableJwt(trimmed);
72
+ this.refreshToken = undefined;
73
+ this.refreshExp = undefined;
74
+ this.absorb({ access_token: trimmed });
75
+ await this.acquireRefreshToken();
76
+ }
77
+ async getAccessToken() {
78
+ if (this.accessToken && !isExpired(this.accessExp)) {
79
+ return this.accessToken;
80
+ }
81
+ // De-duplicate concurrent acquisitions.
82
+ if (!this.inflight) {
83
+ this.inflight = this.acquire().finally(() => {
84
+ this.inflight = undefined;
85
+ });
86
+ }
87
+ return this.inflight;
88
+ }
89
+ async acquire() {
90
+ if (this.refreshToken && !isExpired(this.refreshExp)) {
91
+ try {
92
+ return await this.renew();
93
+ }
94
+ catch (err) {
95
+ // Not fatal: fall back to a full authentication exactly once.
96
+ console.error(`[hubex-mcp] token refresh failed, re-authenticating: ${err.message}`);
97
+ this.refreshToken = undefined;
98
+ this.refreshExp = undefined;
99
+ }
100
+ }
101
+ else if (this.refreshToken) {
102
+ // Протух — выбрасываем, иначе новый не будет запрошен после аутентификации.
103
+ this.refreshToken = undefined;
104
+ this.refreshExp = undefined;
105
+ }
106
+ const token = await this.authenticate();
107
+ if (!this.refreshToken)
108
+ await this.acquireRefreshToken();
109
+ return token;
110
+ }
111
+ /**
112
+ * Renew via the refresh token. `accessJwt` carries the old (expired) token on
113
+ * purpose: AUTHZ reads its LONG_TERM_CLIENT / SHORT_TERM_CLIENT claims to pick
114
+ * the issuer options, and without it the new token silently gets the default
115
+ * (shorter) lifetime. The refresh token itself is not rotated.
116
+ */
117
+ async renew() {
118
+ const result = await this.fetchJwt(`${this.config.apiBaseUrl}/AUTHZ/AccessTokens`, {
119
+ method: "POST",
120
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
121
+ body: JSON.stringify({ refreshJwt: this.refreshToken, accessJwt: this.accessToken }),
122
+ });
123
+ const token = this.absorb(result);
124
+ if (!token) {
125
+ throw new Error("Refresh succeeded but no access_token was returned.");
126
+ }
127
+ return token;
128
+ }
129
+ /** Full authentication: credentials from config, or a token from the user. */
130
+ async authenticate() {
131
+ let result;
132
+ if (this.config.authMode === "service") {
133
+ result = await this.loginByServiceToken();
134
+ }
135
+ else if (this.config.authMode === "token") {
136
+ result = { access_token: await this.promptForToken() };
137
+ }
138
+ else {
139
+ result = await this.loginByPassword();
140
+ }
141
+ const token = this.absorb(result);
142
+ if (!token) {
143
+ throw new Error("Authentication succeeded but no access_token was returned.");
144
+ }
145
+ return token;
146
+ }
147
+ /**
148
+ * Asks the user for an access token. The token itself is the credential —
149
+ * there is nothing to exchange, so we validate it and move straight on to
150
+ * buying a refresh token.
151
+ */
152
+ async promptForToken() {
153
+ if (!this.prompter) {
154
+ throw new Error("Нет способа запросить токен: клиент не поддерживает интерактивный ввод. " +
155
+ "Вызови инструмент hubex_set_token и передай access-токен HubEx.");
156
+ }
157
+ const token = (await this.prompter()).trim();
158
+ assertUsableJwt(token);
159
+ return token;
160
+ }
161
+ loginByPassword() {
162
+ const basic = Buffer.from(`${encodeURIComponent(this.config.username)}:${encodeURIComponent(this.config.password)}`).toString("base64");
163
+ return this.fetchJwt(`${this.config.apiBaseUrl}/AUTHN/Accounts/login`, {
164
+ method: "POST",
165
+ headers: this.authHeaders({ Authorization: `Basic ${basic}` }),
166
+ });
167
+ }
168
+ /** AUTHZ/AccessTokens is anonymous — the service token itself identifies the member. */
169
+ loginByServiceToken() {
170
+ return this.fetchJwt(`${this.config.apiBaseUrl}/AUTHZ/AccessTokens`, {
171
+ method: "POST",
172
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
173
+ body: JSON.stringify({ serviceToken: this.config.serviceToken }),
174
+ });
175
+ }
176
+ /**
177
+ * A service-token login never carries a refresh token, so ask for one.
178
+ * Best-effort: without it we simply re-authenticate when the access token expires.
179
+ */
180
+ async acquireRefreshToken() {
181
+ try {
182
+ const result = await this.fetchJwt(`${this.config.apiBaseUrl}/AUTHZ/RefreshTokens`, {
183
+ method: "GET",
184
+ headers: this.authHeaders({ Authorization: `Bearer ${this.accessToken}` }),
185
+ });
186
+ this.absorb(result);
187
+ }
188
+ catch (err) {
189
+ console.error("[hubex-mcp] could not obtain a refresh token; will re-authenticate on expiry: " +
190
+ `${err.message}`);
191
+ }
192
+ }
193
+ authHeaders(extra) {
194
+ return {
195
+ "X-Application-ID": this.config.applicationId,
196
+ Accept: "application/json",
197
+ ...extra,
198
+ };
199
+ }
200
+ async fetchJwt(url, init) {
201
+ const res = await fetch(url, init);
202
+ if (!res.ok) {
203
+ const body = await res.text().catch(() => "");
204
+ throw new Error(`${init.method ?? "GET"} ${url} failed (HTTP ${res.status}): ${body.slice(0, 500)}`);
205
+ }
206
+ return (await res.json());
207
+ }
208
+ /** Stores whatever the response carried; returns its access token, if any. */
209
+ absorb(result) {
210
+ if (result.access_token) {
211
+ this.accessToken = result.access_token;
212
+ const claims = decodeJwt(result.access_token);
213
+ this.accessExp = claims?.exp;
214
+ if (!this.tenantId)
215
+ this.tenantId = tenantFromClaims(claims);
216
+ }
217
+ if (result.refresh_token) {
218
+ this.refreshToken = result.refresh_token;
219
+ this.refreshExp = decodeJwt(result.refresh_token)?.exp;
220
+ }
221
+ return result.access_token;
222
+ }
223
+ }
224
+ export function createAuthProvider(config) {
225
+ return new RefreshingAuth(config);
226
+ }
package/dist/config.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Configuration loaded from environment variables.
3
+ * See .env.example for the full list.
4
+ */
5
+ export const ALL_ENVS = ["dev", "stg", "prod"];
6
+ /** Хост API окружения: у прода нет префикса. */
7
+ export function apiBaseUrlFor(env) {
8
+ return env === "prod" ? "https://api.hubex.ru/fsm" : `https://${env}-api.hubex.ru/fsm`;
9
+ }
10
+ export const ALL_SWAGGER_CATALOGS = ["dev", "prod"];
11
+ export function swaggerCatalogFor(env) {
12
+ return env === "prod" ? "prod" : "dev";
13
+ }
14
+ export const ALL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"];
15
+ /** Методы, доступные в режиме только-чтение. */
16
+ const READONLY_METHODS = ["GET", "HEAD"];
17
+ function required(name, value) {
18
+ if (!value || value.trim() === "") {
19
+ throw new Error(`Missing required environment variable: ${name}`);
20
+ }
21
+ return value.trim();
22
+ }
23
+ function parseBool(value) {
24
+ if (!value)
25
+ return false;
26
+ const v = value.trim().toLowerCase();
27
+ return v !== "" && v !== "0" && v !== "false" && v !== "no";
28
+ }
29
+ /** Как parseBool, но при отсутствии переменной возвращает true. */
30
+ function parseBoolDefaultTrue(value) {
31
+ if (value === undefined)
32
+ return true;
33
+ const v = value.trim().toLowerCase();
34
+ if (v === "")
35
+ return true;
36
+ return v !== "0" && v !== "false" && v !== "no";
37
+ }
38
+ function parseList(name, value) {
39
+ if (value === undefined)
40
+ return undefined;
41
+ const items = value
42
+ .split(",")
43
+ .map((s) => s.trim().toUpperCase())
44
+ .filter((s) => s !== "");
45
+ if (items.length === 0) {
46
+ throw new Error(`${name} is set but contains no values. Remove it or list values, e.g. ${name}=GET,POST`);
47
+ }
48
+ return items;
49
+ }
50
+ function parseMethods(env, readonly) {
51
+ const raw = parseList("HUBEX_METHODS", env.HUBEX_METHODS);
52
+ let methods = ALL_METHODS;
53
+ if (raw) {
54
+ for (const item of raw) {
55
+ if (!ALL_METHODS.includes(item)) {
56
+ throw new Error(`HUBEX_METHODS contains unknown method "${item}". Allowed: ${ALL_METHODS.join(", ")}`);
57
+ }
58
+ }
59
+ methods = ALL_METHODS.filter((m) => raw.includes(m));
60
+ }
61
+ if (readonly) {
62
+ const narrowed = methods.filter((m) => READONLY_METHODS.includes(m));
63
+ if (narrowed.length === 0) {
64
+ throw new Error(`HUBEX_READONLY=true allows only ${READONLY_METHODS.join(", ")}, ` +
65
+ `but HUBEX_METHODS=${methods.join(",")} leaves nothing enabled`);
66
+ }
67
+ return narrowed;
68
+ }
69
+ return methods;
70
+ }
71
+ function parseIntEnv(name, value) {
72
+ if (value === undefined || value.trim() === "")
73
+ return undefined;
74
+ const n = Number(value.trim());
75
+ if (!Number.isInteger(n)) {
76
+ throw new Error(`${name} must be an integer, got: "${value}"`);
77
+ }
78
+ return n;
79
+ }
80
+ export function loadConfig(env = process.env) {
81
+ const rawEnv = (env.HUBEX_ENV ?? "dev").trim().toLowerCase();
82
+ if (!ALL_ENVS.includes(rawEnv)) {
83
+ throw new Error(`HUBEX_ENV must be one of ${ALL_ENVS.join(", ")}, got: "${rawEnv}"`);
84
+ }
85
+ const hubexEnv = rawEnv;
86
+ const authMode = (env.HUBEX_AUTH_MODE ?? "token").trim().toLowerCase();
87
+ if (authMode !== "token" && authMode !== "login" && authMode !== "service") {
88
+ throw new Error(`HUBEX_AUTH_MODE must be "token", "login" or "service", got: "${authMode}"`);
89
+ }
90
+ const config = {
91
+ env: hubexEnv,
92
+ apiBaseUrl: apiBaseUrlFor(hubexEnv),
93
+ swaggerCatalog: swaggerCatalogFor(hubexEnv),
94
+ applicationId: required("HUBEX_APPLICATION_ID", env.HUBEX_APPLICATION_ID),
95
+ tenantId: env.HUBEX_TENANT_ID?.trim() || undefined,
96
+ authMode: authMode,
97
+ readonly: parseBool(env.HUBEX_READONLY),
98
+ maskPii: parseBoolDefaultTrue(env.HUBEX_MASK_PII),
99
+ methods: parseMethods(env, parseBool(env.HUBEX_READONLY)),
100
+ services: parseList("HUBEX_SERVICES", env.HUBEX_SERVICES),
101
+ defaults: {
102
+ requestMethodID: parseIntEnv("HUBEX_DEFAULT_REQUEST_METHOD_ID", env.HUBEX_DEFAULT_REQUEST_METHOD_ID),
103
+ taskTypeID: parseIntEnv("HUBEX_DEFAULT_TASK_TYPE_ID", env.HUBEX_DEFAULT_TASK_TYPE_ID),
104
+ companyID: parseIntEnv("HUBEX_DEFAULT_COMPANY_ID", env.HUBEX_DEFAULT_COMPANY_ID),
105
+ },
106
+ };
107
+ for (const key of Object.keys(config.defaults)) {
108
+ if (config.defaults[key] === undefined)
109
+ delete config.defaults[key];
110
+ }
111
+ // Режим "token" ничего не читает из окружения: токен запрашивается у пользователя.
112
+ if (authMode === "service") {
113
+ config.serviceToken = required("HUBEX_SERVICE_TOKEN", env.HUBEX_SERVICE_TOKEN);
114
+ }
115
+ else if (authMode === "login") {
116
+ config.username = required("HUBEX_USERNAME", env.HUBEX_USERNAME);
117
+ config.password = required("HUBEX_PASSWORD", env.HUBEX_PASSWORD);
118
+ }
119
+ return config;
120
+ }