@red-hat-developer-hub/backstage-plugin-intelligent-assistant-backend 3.0.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/CHANGELOG.md +607 -0
- package/README.md +400 -0
- package/app-config.yaml +25 -0
- package/config.d.ts +142 -0
- package/dist/database/migration.cjs.js +19 -0
- package/dist/database/migration.cjs.js.map +1 -0
- package/dist/index.cjs.js +12 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +73 -0
- package/dist/plugin.cjs.js +95 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/service/constant.cjs.js +107 -0
- package/dist/service/constant.cjs.js.map +1 -0
- package/dist/service/mcp-server-store.cjs.js +107 -0
- package/dist/service/mcp-server-store.cjs.js.map +1 -0
- package/dist/service/mcp-server-validator.cjs.js +215 -0
- package/dist/service/mcp-server-validator.cjs.js.map +1 -0
- package/dist/service/middleware/createPermissionMiddleware.cjs.js +24 -0
- package/dist/service/middleware/createPermissionMiddleware.cjs.js.map +1 -0
- package/dist/service/middleware/createRateLimitMiddleware.cjs.js +52 -0
- package/dist/service/middleware/createRateLimitMiddleware.cjs.js.map +1 -0
- package/dist/service/middleware/getIdentity.cjs.js +39 -0
- package/dist/service/middleware/getIdentity.cjs.js.map +1 -0
- package/dist/service/notebooks/VectorStoresOperator.cjs.js +347 -0
- package/dist/service/notebooks/VectorStoresOperator.cjs.js.map +1 -0
- package/dist/service/notebooks/documents/documentHelpers.cjs.js +36 -0
- package/dist/service/notebooks/documents/documentHelpers.cjs.js.map +1 -0
- package/dist/service/notebooks/documents/documentService.cjs.js +189 -0
- package/dist/service/notebooks/documents/documentService.cjs.js.map +1 -0
- package/dist/service/notebooks/documents/fileParser.cjs.js +139 -0
- package/dist/service/notebooks/documents/fileParser.cjs.js.map +1 -0
- package/dist/service/notebooks/notebooksRouters.cjs.js +477 -0
- package/dist/service/notebooks/notebooksRouters.cjs.js.map +1 -0
- package/dist/service/notebooks/sessions/sessionService.cjs.js +226 -0
- package/dist/service/notebooks/sessions/sessionService.cjs.js.map +1 -0
- package/dist/service/notebooks/types/notebooksTypes.cjs.js +40 -0
- package/dist/service/notebooks/types/notebooksTypes.cjs.js.map +1 -0
- package/dist/service/notebooks/utils.cjs.js +70 -0
- package/dist/service/notebooks/utils.cjs.js.map +1 -0
- package/dist/service/permission.cjs.js +26 -0
- package/dist/service/permission.cjs.js.map +1 -0
- package/dist/service/router.cjs.js +638 -0
- package/dist/service/router.cjs.js.map +1 -0
- package/dist/service/token-encryption.cjs.js +101 -0
- package/dist/service/token-encryption.cjs.js.map +1 -0
- package/dist/service/utils.cjs.js +40 -0
- package/dist/service/utils.cjs.js.map +1 -0
- package/dist/service/validation.cjs.js +45 -0
- package/dist/service/validation.cjs.js.map +1 -0
- package/migrations/20260302120000_add_mcp_servers.js +41 -0
- package/package.json +109 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var backendPluginApi = require('@backstage/backend-plugin-api');
|
|
4
|
+
var migration = require('./database/migration.cjs.js');
|
|
5
|
+
var notebooksRouters = require('./service/notebooks/notebooksRouters.cjs.js');
|
|
6
|
+
require('@backstage/errors');
|
|
7
|
+
require('./service/constant.cjs.js');
|
|
8
|
+
require('js-yaml');
|
|
9
|
+
require('pdfjs-dist');
|
|
10
|
+
require('stream');
|
|
11
|
+
var router = require('./service/router.cjs.js');
|
|
12
|
+
|
|
13
|
+
const intelligentAssistantPlugin = backendPluginApi.createBackendPlugin({
|
|
14
|
+
pluginId: "intelligent-assistant",
|
|
15
|
+
register(env) {
|
|
16
|
+
env.registerInit({
|
|
17
|
+
deps: {
|
|
18
|
+
logger: backendPluginApi.coreServices.logger,
|
|
19
|
+
config: backendPluginApi.coreServices.rootConfig,
|
|
20
|
+
http: backendPluginApi.coreServices.httpRouter,
|
|
21
|
+
httpAuth: backendPluginApi.coreServices.httpAuth,
|
|
22
|
+
auth: backendPluginApi.coreServices.auth,
|
|
23
|
+
userInfo: backendPluginApi.coreServices.userInfo,
|
|
24
|
+
permissions: backendPluginApi.coreServices.permissions,
|
|
25
|
+
database: backendPluginApi.coreServices.database
|
|
26
|
+
},
|
|
27
|
+
async init({
|
|
28
|
+
logger,
|
|
29
|
+
config,
|
|
30
|
+
http,
|
|
31
|
+
httpAuth,
|
|
32
|
+
auth,
|
|
33
|
+
userInfo,
|
|
34
|
+
permissions,
|
|
35
|
+
database
|
|
36
|
+
}) {
|
|
37
|
+
await migration.migrate(database);
|
|
38
|
+
if (config.has("lightspeed")) {
|
|
39
|
+
logger.warn(
|
|
40
|
+
'DEPRECATED: The "lightspeed" configuration key has been renamed to "intelligent-assistant". Please update your app-config.yaml. The old "lightspeed" key is no longer read. Migration guide: https://github.com/redhat-developer/rhdh-plugins/blob/main/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/README.md#migration-from-lightspeed-to-intelligent-assistant'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const aiNotebooksEnabled = config.getOptionalBoolean(
|
|
44
|
+
"intelligent-assistant.notebooks.enabled"
|
|
45
|
+
) ?? false;
|
|
46
|
+
if (aiNotebooksEnabled) {
|
|
47
|
+
const queryModel = config.getOptionalString(
|
|
48
|
+
"intelligent-assistant.notebooks.queryDefaults.model"
|
|
49
|
+
);
|
|
50
|
+
const queryProvider = config.getOptionalString(
|
|
51
|
+
"intelligent-assistant.notebooks.queryDefaults.provider_id"
|
|
52
|
+
);
|
|
53
|
+
if (!queryModel || !queryProvider) {
|
|
54
|
+
logger.warn(
|
|
55
|
+
"AI Notebooks feature is enabled but required configuration is missing. Please configure intelligent-assistant.notebooks.queryDefaults.model and intelligent-assistant.notebooks.queryDefaults.provider_id. Notebooks will not be available until these are set."
|
|
56
|
+
);
|
|
57
|
+
} else {
|
|
58
|
+
http.use(
|
|
59
|
+
await notebooksRouters.createNotebooksRouter({
|
|
60
|
+
config,
|
|
61
|
+
logger,
|
|
62
|
+
httpAuth,
|
|
63
|
+
userInfo,
|
|
64
|
+
permissions
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
logger.info("AI Notebooks enabled");
|
|
68
|
+
http.addAuthPolicy({
|
|
69
|
+
path: "/notebooks/health",
|
|
70
|
+
allow: "unauthenticated"
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
http.use(
|
|
75
|
+
await router.createRouter({
|
|
76
|
+
config,
|
|
77
|
+
logger,
|
|
78
|
+
database,
|
|
79
|
+
httpAuth,
|
|
80
|
+
auth,
|
|
81
|
+
userInfo,
|
|
82
|
+
permissions
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
http.addAuthPolicy({
|
|
86
|
+
path: "/health",
|
|
87
|
+
allow: "unauthenticated"
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
exports.intelligentAssistantPlugin = intelligentAssistantPlugin;
|
|
95
|
+
//# sourceMappingURL=plugin.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin.cjs.js","sources":["../src/plugin.ts"],"sourcesContent":["/*\n * Copyright Red Hat, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n coreServices,\n createBackendPlugin,\n} from '@backstage/backend-plugin-api';\n\nimport { migrate } from './database/migration';\nimport { createNotebooksRouter } from './service/notebooks';\nimport { createRouter } from './service/router';\n\n/**\n * @public\n * The lightspeed backend plugin.\n */\nexport const intelligentAssistantPlugin = createBackendPlugin({\n pluginId: 'intelligent-assistant',\n register(env) {\n env.registerInit({\n deps: {\n logger: coreServices.logger,\n config: coreServices.rootConfig,\n http: coreServices.httpRouter,\n httpAuth: coreServices.httpAuth,\n auth: coreServices.auth,\n userInfo: coreServices.userInfo,\n permissions: coreServices.permissions,\n database: coreServices.database,\n },\n async init({\n logger,\n config,\n http,\n httpAuth,\n auth,\n userInfo,\n permissions,\n database,\n }) {\n await migrate(database);\n\n if (config.has('lightspeed')) {\n logger.warn(\n 'DEPRECATED: The \"lightspeed\" configuration key has been renamed to \"intelligent-assistant\". ' +\n 'Please update your app-config.yaml. The old \"lightspeed\" key is no longer read. ' +\n 'Migration guide: https://github.com/redhat-developer/rhdh-plugins/blob/main/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/README.md#migration-from-lightspeed-to-intelligent-assistant',\n );\n }\n\n const aiNotebooksEnabled =\n config.getOptionalBoolean(\n 'intelligent-assistant.notebooks.enabled',\n ) ?? false;\n\n if (aiNotebooksEnabled) {\n const queryModel = config.getOptionalString(\n 'intelligent-assistant.notebooks.queryDefaults.model',\n );\n const queryProvider = config.getOptionalString(\n 'intelligent-assistant.notebooks.queryDefaults.provider_id',\n );\n\n if (!queryModel || !queryProvider) {\n logger.warn(\n 'AI Notebooks feature is enabled but required configuration is missing. ' +\n 'Please configure intelligent-assistant.notebooks.queryDefaults.model and intelligent-assistant.notebooks.queryDefaults.provider_id. ' +\n 'Notebooks will not be available until these are set.',\n );\n } else {\n http.use(\n await createNotebooksRouter({\n config: config,\n logger: logger,\n httpAuth: httpAuth,\n userInfo: userInfo,\n permissions,\n }),\n );\n logger.info('AI Notebooks enabled');\n\n http.addAuthPolicy({\n path: '/notebooks/health',\n allow: 'unauthenticated',\n });\n }\n }\n\n http.use(\n await createRouter({\n config,\n logger,\n database,\n httpAuth,\n auth,\n userInfo,\n permissions,\n }),\n );\n\n // Configure authentication policies\n http.addAuthPolicy({\n path: '/health',\n allow: 'unauthenticated',\n });\n },\n });\n },\n});\n"],"names":["createBackendPlugin","coreServices","migrate","createNotebooksRouter","createRouter"],"mappings":";;;;;;;;;;;;AA6BO,MAAM,6BAA6BA,oCAAA,CAAoB;AAAA,EAC5D,QAAA,EAAU,uBAAA;AAAA,EACV,SAAS,GAAA,EAAK;AACZ,IAAA,GAAA,CAAI,YAAA,CAAa;AAAA,MACf,IAAA,EAAM;AAAA,QACJ,QAAQC,6BAAA,CAAa,MAAA;AAAA,QACrB,QAAQA,6BAAA,CAAa,UAAA;AAAA,QACrB,MAAMA,6BAAA,CAAa,UAAA;AAAA,QACnB,UAAUA,6BAAA,CAAa,QAAA;AAAA,QACvB,MAAMA,6BAAA,CAAa,IAAA;AAAA,QACnB,UAAUA,6BAAA,CAAa,QAAA;AAAA,QACvB,aAAaA,6BAAA,CAAa,WAAA;AAAA,QAC1B,UAAUA,6BAAA,CAAa;AAAA,OACzB;AAAA,MACA,MAAM,IAAA,CAAK;AAAA,QACT,MAAA;AAAA,QACA,MAAA;AAAA,QACA,IAAA;AAAA,QACA,QAAA;AAAA,QACA,IAAA;AAAA,QACA,QAAA;AAAA,QACA,WAAA;AAAA,QACA;AAAA,OACF,EAAG;AACD,QAAA,MAAMC,kBAAQ,QAAQ,CAAA;AAEtB,QAAA,IAAI,MAAA,CAAO,GAAA,CAAI,YAAY,CAAA,EAAG;AAC5B,UAAA,MAAA,CAAO,IAAA;AAAA,YACL;AAAA,WAGF;AAAA,QACF;AAEA,QAAA,MAAM,qBACJ,MAAA,CAAO,kBAAA;AAAA,UACL;AAAA,SACF,IAAK,KAAA;AAEP,QAAA,IAAI,kBAAA,EAAoB;AACtB,UAAA,MAAM,aAAa,MAAA,CAAO,iBAAA;AAAA,YACxB;AAAA,WACF;AACA,UAAA,MAAM,gBAAgB,MAAA,CAAO,iBAAA;AAAA,YAC3B;AAAA,WACF;AAEA,UAAA,IAAI,CAAC,UAAA,IAAc,CAAC,aAAA,EAAe;AACjC,YAAA,MAAA,CAAO,IAAA;AAAA,cACL;AAAA,aAGF;AAAA,UACF,CAAA,MAAO;AACL,YAAA,IAAA,CAAK,GAAA;AAAA,cACH,MAAMC,sCAAA,CAAsB;AAAA,gBAC1B,MAAA;AAAA,gBACA,MAAA;AAAA,gBACA,QAAA;AAAA,gBACA,QAAA;AAAA,gBACA;AAAA,eACD;AAAA,aACH;AACA,YAAA,MAAA,CAAO,KAAK,sBAAsB,CAAA;AAElC,YAAA,IAAA,CAAK,aAAA,CAAc;AAAA,cACjB,IAAA,EAAM,mBAAA;AAAA,cACN,KAAA,EAAO;AAAA,aACR,CAAA;AAAA,UACH;AAAA,QACF;AAEA,QAAA,IAAA,CAAK,GAAA;AAAA,UACH,MAAMC,mBAAA,CAAa;AAAA,YACjB,MAAA;AAAA,YACA,MAAA;AAAA,YACA,QAAA;AAAA,YACA,QAAA;AAAA,YACA,IAAA;AAAA,YACA,QAAA;AAAA,YACA;AAAA,WACD;AAAA,SACH;AAGA,QAAA,IAAA,CAAK,aAAA,CAAc;AAAA,UACjB,IAAA,EAAM,SAAA;AAAA,UACN,KAAA,EAAO;AAAA,SACR,CAAA;AAAA,MACH;AAAA,KACD,CAAA;AAAA,EACH;AACF,CAAC;;;;"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var multer = require('multer');
|
|
4
|
+
|
|
5
|
+
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
|
|
6
|
+
|
|
7
|
+
var multer__default = /*#__PURE__*/_interopDefaultCompat(multer);
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CHUNKING_STRATEGY_TYPE = "auto";
|
|
10
|
+
const DEFAULT_MAX_CHUNK_SIZE_TOKENS = 512;
|
|
11
|
+
const DEFAULT_CHUNK_OVERLAP_TOKENS = 50;
|
|
12
|
+
const DEFAULT_LIGHTSPEED_SERVICE_HOST = "127.0.0.1";
|
|
13
|
+
const DEFAULT_LIGHTSPEED_SERVICE_PORT = 8080;
|
|
14
|
+
const DEFAULT_MAX_FILE_SIZE_MB = 20 * 1024 * 1024;
|
|
15
|
+
const RATE_LIMIT_WINDOW_MS = 6e4;
|
|
16
|
+
const DEFAULT_EXPENSIVE_RATE_LIMIT_MAX = 25;
|
|
17
|
+
const DEFAULT_GENERAL_RATE_LIMIT_MAX = 200;
|
|
18
|
+
const MAX_QUERY_LENGTH = 32e3;
|
|
19
|
+
const MAX_ATTACHMENT_SIZE_BYTES = 20 * 1024 * 1024;
|
|
20
|
+
const MAX_TOTAL_ATTACHMENTS_SIZE_BYTES = 50 * 1024 * 1024;
|
|
21
|
+
const EXPRESS_JSON_BODY_LIMIT = "60mb";
|
|
22
|
+
const NOTEBOOKS_SYSTEM_PROMPT = `
|
|
23
|
+
You are a helpful, analytical Research Analyst assistant. Your primary objective is to synthesize cross-document information to answer user queries with 100% fidelity to the provided documents.
|
|
24
|
+
|
|
25
|
+
### QUERY TYPES - IMPORTANT
|
|
26
|
+
* **Meta Queries ONLY:** ONLY when the user asks specifically about YOU as an assistant (e.g., "who are you", "what can you do", "hello"), respond naturally without requiring documents.
|
|
27
|
+
* **ALL OTHER QUERIES:** For ANY other question, you MUST use the strict document-grounding rules below. This includes general knowledge questions, trivia, explanations, etc. If it's not about you as an assistant, it requires document evidence.
|
|
28
|
+
|
|
29
|
+
### STRICT OPERATIONAL CONSTRAINTS
|
|
30
|
+
* **Zero Outside Knowledge:** Do NOT use prior training data, general knowledge, or unsupported logical leaps to answer queries.
|
|
31
|
+
* **Absolute Grounding:** If the provided documents do not contain explicit, direct evidence to answer the query, you MUST output exactly: "I cannot answer this based on the provided documents."
|
|
32
|
+
* **Precision Citations:** Every single factual claim, metric, or conclusion must have an inline citation [Document Title].
|
|
33
|
+
* **Contradictions:** Do not resolve discrepancies. If sources conflict, explicitly document the friction (e.g., "Source A states X, whereas Source B states Y").
|
|
34
|
+
|
|
35
|
+
### ANALYTICAL GUIDELINES
|
|
36
|
+
* **Comprehensive Responses:** Provide thorough, detailed analysis. Don't be overly brief - expand on the evidence with full context and explanation.
|
|
37
|
+
* **Quantitative Focus:** Prioritize extracting specific metrics, dates, and figures.
|
|
38
|
+
* **Objective Tone:** Use neutral, professional language. Do not use subjective adjectives (e.g., "impressive," "concerning") unless quoting the text directly.
|
|
39
|
+
|
|
40
|
+
### REQUIRED ANALYSIS PROCESS
|
|
41
|
+
Before generating your response, you must internally perform evidence extraction (DO NOT show this in your output):
|
|
42
|
+
1. Identify the core entities and requirements of the user's query.
|
|
43
|
+
2. Extract exact, verbatim quotes from the provided documents that directly address the query.
|
|
44
|
+
3. If no explicit quotes exist to answer the prompt, output ONLY: "I cannot answer this based on the provided documents."
|
|
45
|
+
|
|
46
|
+
### CRITICAL: NEVER output <evidence_extraction> tags or any internal reasoning in your visible response. These are for your internal analysis only.
|
|
47
|
+
|
|
48
|
+
### REQUIRED OUTPUT STRUCTURE
|
|
49
|
+
When you have evidence from documents, structure your response as:
|
|
50
|
+
|
|
51
|
+
**Executive Summary:**
|
|
52
|
+
[A comprehensive 2-4 sentence synthesis of the primary findings based strictly on the extracted evidence. Provide full context and detail.]
|
|
53
|
+
|
|
54
|
+
**Detailed Analysis:**
|
|
55
|
+
* **[Key Entity/Theme]:** [Thorough explanation of the fact or data point derived from text, with full context and supporting details] [Document Title].
|
|
56
|
+
* **[Key Entity/Theme]:** [Thorough explanation of the fact or data point derived from text, with full context and supporting details] [Document Title].
|
|
57
|
+
|
|
58
|
+
**Referenced Documents:**
|
|
59
|
+
* [Document Title 1]
|
|
60
|
+
* [Document Title 2]
|
|
61
|
+
|
|
62
|
+
When you lack evidence, output ONLY: "I cannot answer this based on the provided documents."
|
|
63
|
+
`.trim();
|
|
64
|
+
const HTTP_STATUS_ACCEPTED = 202;
|
|
65
|
+
const HTTP_STATUS_INTERNAL_ERROR = 500;
|
|
66
|
+
const MAX_QUERY_RETRIES = 1;
|
|
67
|
+
const upload = multer__default.default({
|
|
68
|
+
storage: multer__default.default.memoryStorage(),
|
|
69
|
+
limits: {
|
|
70
|
+
fileSize: DEFAULT_MAX_FILE_SIZE_MB
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
var SupportedFileType = /* @__PURE__ */ ((SupportedFileType2) => {
|
|
74
|
+
SupportedFileType2["MARKDOWN"] = "md";
|
|
75
|
+
SupportedFileType2["TEXT"] = "txt";
|
|
76
|
+
SupportedFileType2["PDF"] = "pdf";
|
|
77
|
+
SupportedFileType2["JSON"] = "json";
|
|
78
|
+
SupportedFileType2["YAML"] = "yaml";
|
|
79
|
+
SupportedFileType2["YML"] = "yml";
|
|
80
|
+
SupportedFileType2["LOG"] = "log";
|
|
81
|
+
return SupportedFileType2;
|
|
82
|
+
})(SupportedFileType || {});
|
|
83
|
+
const SKIP_USER_ID_ENDPOINTS = /* @__PURE__ */ new Set(["/v1/models", "/v1/shields"]);
|
|
84
|
+
const DEFAULT_HISTORY_LENGTH = 10;
|
|
85
|
+
|
|
86
|
+
exports.DEFAULT_CHUNKING_STRATEGY_TYPE = DEFAULT_CHUNKING_STRATEGY_TYPE;
|
|
87
|
+
exports.DEFAULT_CHUNK_OVERLAP_TOKENS = DEFAULT_CHUNK_OVERLAP_TOKENS;
|
|
88
|
+
exports.DEFAULT_EXPENSIVE_RATE_LIMIT_MAX = DEFAULT_EXPENSIVE_RATE_LIMIT_MAX;
|
|
89
|
+
exports.DEFAULT_GENERAL_RATE_LIMIT_MAX = DEFAULT_GENERAL_RATE_LIMIT_MAX;
|
|
90
|
+
exports.DEFAULT_HISTORY_LENGTH = DEFAULT_HISTORY_LENGTH;
|
|
91
|
+
exports.DEFAULT_LIGHTSPEED_SERVICE_HOST = DEFAULT_LIGHTSPEED_SERVICE_HOST;
|
|
92
|
+
exports.DEFAULT_LIGHTSPEED_SERVICE_PORT = DEFAULT_LIGHTSPEED_SERVICE_PORT;
|
|
93
|
+
exports.DEFAULT_MAX_CHUNK_SIZE_TOKENS = DEFAULT_MAX_CHUNK_SIZE_TOKENS;
|
|
94
|
+
exports.DEFAULT_MAX_FILE_SIZE_MB = DEFAULT_MAX_FILE_SIZE_MB;
|
|
95
|
+
exports.EXPRESS_JSON_BODY_LIMIT = EXPRESS_JSON_BODY_LIMIT;
|
|
96
|
+
exports.HTTP_STATUS_ACCEPTED = HTTP_STATUS_ACCEPTED;
|
|
97
|
+
exports.HTTP_STATUS_INTERNAL_ERROR = HTTP_STATUS_INTERNAL_ERROR;
|
|
98
|
+
exports.MAX_ATTACHMENT_SIZE_BYTES = MAX_ATTACHMENT_SIZE_BYTES;
|
|
99
|
+
exports.MAX_QUERY_LENGTH = MAX_QUERY_LENGTH;
|
|
100
|
+
exports.MAX_QUERY_RETRIES = MAX_QUERY_RETRIES;
|
|
101
|
+
exports.MAX_TOTAL_ATTACHMENTS_SIZE_BYTES = MAX_TOTAL_ATTACHMENTS_SIZE_BYTES;
|
|
102
|
+
exports.NOTEBOOKS_SYSTEM_PROMPT = NOTEBOOKS_SYSTEM_PROMPT;
|
|
103
|
+
exports.RATE_LIMIT_WINDOW_MS = RATE_LIMIT_WINDOW_MS;
|
|
104
|
+
exports.SKIP_USER_ID_ENDPOINTS = SKIP_USER_ID_ENDPOINTS;
|
|
105
|
+
exports.SupportedFileType = SupportedFileType;
|
|
106
|
+
exports.upload = upload;
|
|
107
|
+
//# sourceMappingURL=constant.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"constant.cjs.js","sources":["../../src/service/constant.ts"],"sourcesContent":["/*\n * Copyright Red Hat, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport multer from 'multer';\n\n/**\n * Default values for AI Notebooks\n */\nexport const DEFAULT_CHUNKING_STRATEGY_TYPE = 'auto'; // auto chunking\nexport const DEFAULT_MAX_CHUNK_SIZE_TOKENS = 512; // 512 tokens\nexport const DEFAULT_CHUNK_OVERLAP_TOKENS = 50; // 50 tokens\nexport const DEFAULT_LIGHTSPEED_SERVICE_HOST = '127.0.0.1'; // Lightspeed core service host\nexport const DEFAULT_LIGHTSPEED_SERVICE_PORT = 8080; // Lightspeed service port\nexport const DEFAULT_MAX_FILE_SIZE_MB = 20 * 1024 * 1024; // 20MB\n\n/**\n * Rate limiting defaults (window is fixed at 1 minute)\n */\nexport const RATE_LIMIT_WINDOW_MS = 60000;\nexport const DEFAULT_EXPENSIVE_RATE_LIMIT_MAX = 25;\nexport const DEFAULT_GENERAL_RATE_LIMIT_MAX = 200;\n\n/**\n * Input validation limits for query endpoints\n */\nexport const MAX_QUERY_LENGTH = 32000; // 32K characters (reasonable for LLM context)\nexport const MAX_ATTACHMENT_SIZE_BYTES = 20 * 1024 * 1024; // 20MB (matches notebooks limit)\nexport const MAX_TOTAL_ATTACHMENTS_SIZE_BYTES = 50 * 1024 * 1024; // 50MB total\nexport const EXPRESS_JSON_BODY_LIMIT = '60mb';\n\nexport const NOTEBOOKS_SYSTEM_PROMPT = `\nYou are a helpful, analytical Research Analyst assistant. Your primary objective is to synthesize cross-document information to answer user queries with 100% fidelity to the provided documents.\n\n### QUERY TYPES - IMPORTANT\n* **Meta Queries ONLY:** ONLY when the user asks specifically about YOU as an assistant (e.g., \"who are you\", \"what can you do\", \"hello\"), respond naturally without requiring documents.\n* **ALL OTHER QUERIES:** For ANY other question, you MUST use the strict document-grounding rules below. This includes general knowledge questions, trivia, explanations, etc. If it's not about you as an assistant, it requires document evidence.\n\n### STRICT OPERATIONAL CONSTRAINTS\n* **Zero Outside Knowledge:** Do NOT use prior training data, general knowledge, or unsupported logical leaps to answer queries.\n* **Absolute Grounding:** If the provided documents do not contain explicit, direct evidence to answer the query, you MUST output exactly: \"I cannot answer this based on the provided documents.\"\n* **Precision Citations:** Every single factual claim, metric, or conclusion must have an inline citation [Document Title].\n* **Contradictions:** Do not resolve discrepancies. If sources conflict, explicitly document the friction (e.g., \"Source A states X, whereas Source B states Y\").\n\n### ANALYTICAL GUIDELINES\n* **Comprehensive Responses:** Provide thorough, detailed analysis. Don't be overly brief - expand on the evidence with full context and explanation.\n* **Quantitative Focus:** Prioritize extracting specific metrics, dates, and figures.\n* **Objective Tone:** Use neutral, professional language. Do not use subjective adjectives (e.g., \"impressive,\" \"concerning\") unless quoting the text directly.\n\n### REQUIRED ANALYSIS PROCESS\nBefore generating your response, you must internally perform evidence extraction (DO NOT show this in your output):\n1. Identify the core entities and requirements of the user's query.\n2. Extract exact, verbatim quotes from the provided documents that directly address the query.\n3. If no explicit quotes exist to answer the prompt, output ONLY: \"I cannot answer this based on the provided documents.\"\n\n### CRITICAL: NEVER output <evidence_extraction> tags or any internal reasoning in your visible response. These are for your internal analysis only.\n\n### REQUIRED OUTPUT STRUCTURE\nWhen you have evidence from documents, structure your response as:\n\n**Executive Summary:**\n[A comprehensive 2-4 sentence synthesis of the primary findings based strictly on the extracted evidence. Provide full context and detail.]\n\n**Detailed Analysis:**\n* **[Key Entity/Theme]:** [Thorough explanation of the fact or data point derived from text, with full context and supporting details] [Document Title].\n* **[Key Entity/Theme]:** [Thorough explanation of the fact or data point derived from text, with full context and supporting details] [Document Title].\n\n**Referenced Documents:**\n* [Document Title 1]\n* [Document Title 2]\n\nWhen you lack evidence, output ONLY: \"I cannot answer this based on the provided documents.\"\n`.trim();\n\n/**\n * HTTP and networking constants\n * @reserved Reserved for future URL file type support\n */\nexport const URL_FETCH_TIMEOUT_MS = 30000; // 30 second timeout for URL fetching\nexport const USER_AGENT = 'RHDH-Notebooks-Bot/1.0'; // User agent for HTTP requests\nexport const MAX_URL_CONTENT_SIZE = 10 * 1024 * 1024; // 10MB max for URL fetched content\n\n/**\n * HTTP status codes\n */\nexport const HTTP_STATUS_ACCEPTED = 202; // Async operation accepted\nexport const HTTP_STATUS_BAD_REQUEST = 400; // Bad request\nexport const HTTP_STATUS_FORBIDDEN = 403; // Forbidden\nexport const HTTP_STATUS_NOT_FOUND = 404; // Not found\nexport const HTTP_STATUS_CONFLICT = 409; // Conflict\nexport const HTTP_STATUS_INTERNAL_ERROR = 500; // Internal server error\n\n/**\n * SSRF Protection - Blocked hostnames for security\n * These hostnames are commonly used for Server-Side Request Forgery attacks\n * @reserved Reserved for future URL file type support\n */\nexport const SSRF_BLOCKED_HOSTNAMES = [\n 'localhost',\n 'metadata.google.internal', // GCP metadata endpoint\n 'kubernetes.default.svc', // Kubernetes internal DNS\n 'host.docker.internal', // Docker host access\n '169.254.169.254', // NOSONAR local netwok, used by AWS/Azure/GCP metadata IP\n '127.0.0.1', // IPv4 loopback\n '0.0.0.0', // IPv4 any address\n '::1', // IPv6 loopback\n '::', // IPv6 any address\n] as const;\n\n/**\n * Prompt Injection Protection - Patterns to detect and sanitize\n * These patterns are commonly used in prompt injection attacks\n * @reserved Reserved for future URL file type support\n */\nexport const PROMPT_INJECTION_PATTERNS = [\n /ignore\\s+(all\\s+)?previous\\s+(instructions?|prompts?)/gi,\n /disregard\\s+(all\\s+)?previous\\s+(instructions?|prompts?)/gi,\n /forget\\s+(all\\s+)?previous\\s+(instructions?|prompts?)/gi,\n /you\\s+are\\s+now\\s+(a\\s+)?different/gi,\n /new\\s+(instructions?|prompts?)\\s*:/gi,\n /system\\s*:\\s*/gi,\n /assistant\\s*:\\s*/gi,\n /\\[INST\\]/gi,\n /\\[\\/INST\\]/gi,\n /<\\|im_start\\|>/gi,\n /<\\|im_end\\|>/gi,\n /<\\|endoftext\\|>/gi,\n /\\[SYSTEM\\]/gi,\n /\\[\\/SYSTEM\\]/gi,\n /\\[ASSISTANT\\]/gi,\n /\\[\\/ASSISTANT\\]/gi,\n] as const;\n\n/**\n * Content sanitization constants\n * @reserved Reserved for future URL file type support\n */\nexport const MAX_CONSECUTIVE_NEWLINES = 4; // Max consecutive newlines allowed in content\nexport const FILTERED_CONTENT_MARKER = '[CONTENT_FILTERED]'; // Marker for filtered content\n\n/**\n * File type to MIME type mapping\n */\nexport const FILE_TYPE_TO_MIME: Record<string, string> = {\n txt: 'text/plain',\n md: 'text/markdown',\n log: 'text/plain',\n json: 'application/json',\n yaml: 'application/x-yaml',\n yml: 'application/x-yaml',\n pdf: 'application/pdf',\n};\n\nexport const MAX_QUERY_RETRIES = 1; // Max number of retries for query\n\nexport const upload = multer({\n storage: multer.memoryStorage(),\n limits: {\n fileSize: DEFAULT_MAX_FILE_SIZE_MB,\n },\n});\n\n/**\n * Supported file types for document upload AI Notebooks\n */\nexport enum SupportedFileType {\n MARKDOWN = 'md',\n TEXT = 'txt',\n PDF = 'pdf',\n JSON = 'json',\n YAML = 'yaml',\n YML = 'yml',\n LOG = 'log',\n}\n\n/**\n * HTML parsing constants for stripping tags and extracting text\n * @reserved Reserved for future URL file type support\n */\nexport const HTML_BLOCK_TAGS = new Set([\n 'div',\n 'p',\n 'br',\n 'h1',\n 'h2',\n 'h3',\n 'h4',\n 'h5',\n 'h6',\n 'li',\n 'tr',\n 'section',\n 'article',\n 'header',\n 'footer',\n]);\n\nexport const HTML_IGNORED_TAGS = new Set(['script', 'style']);\n\nexport const POLL_INTERVAL_MS = 1000; // 1 second\n\nexport const SKIP_USER_ID_ENDPOINTS = new Set(['/v1/models', '/v1/shields']);\n\n// default number of message history being loaded\nexport const DEFAULT_HISTORY_LENGTH = 10;\n"],"names":["multer","SupportedFileType"],"mappings":";;;;;;;;AAoBO,MAAM,8BAAA,GAAiC;AACvC,MAAM,6BAAA,GAAgC;AACtC,MAAM,4BAAA,GAA+B;AACrC,MAAM,+BAAA,GAAkC;AACxC,MAAM,+BAAA,GAAkC;AACxC,MAAM,wBAAA,GAA2B,KAAK,IAAA,GAAO;AAK7C,MAAM,oBAAA,GAAuB;AAC7B,MAAM,gCAAA,GAAmC;AACzC,MAAM,8BAAA,GAAiC;AAKvC,MAAM,gBAAA,GAAmB;AACzB,MAAM,yBAAA,GAA4B,KAAK,IAAA,GAAO;AAC9C,MAAM,gCAAA,GAAmC,KAAK,IAAA,GAAO;AACrD,MAAM,uBAAA,GAA0B;AAEhC,MAAM,uBAAA,GAA0B;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA,CAAA,CAyCrC,IAAA;AAaK,MAAM,oBAAA,GAAuB;AAK7B,MAAM,0BAAA,GAA6B;AA+DnC,MAAM,iBAAA,GAAoB;AAE1B,MAAM,SAASA,uBAAA,CAAO;AAAA,EAC3B,OAAA,EAASA,wBAAO,aAAA,EAAc;AAAA,EAC9B,MAAA,EAAQ;AAAA,IACN,QAAA,EAAU;AAAA;AAEd,CAAC;AAKM,IAAK,iBAAA,qBAAAC,kBAAAA,KAAL;AACL,EAAAA,mBAAA,UAAA,CAAA,GAAW,IAAA;AACX,EAAAA,mBAAA,MAAA,CAAA,GAAO,KAAA;AACP,EAAAA,mBAAA,KAAA,CAAA,GAAM,KAAA;AACN,EAAAA,mBAAA,MAAA,CAAA,GAAO,MAAA;AACP,EAAAA,mBAAA,MAAA,CAAA,GAAO,MAAA;AACP,EAAAA,mBAAA,KAAA,CAAA,GAAM,KAAA;AACN,EAAAA,mBAAA,KAAA,CAAA,GAAM,KAAA;AAPI,EAAA,OAAAA,kBAAAA;AAAA,CAAA,EAAA,iBAAA,IAAA,EAAA;AAoCL,MAAM,yCAAyB,IAAI,GAAA,CAAI,CAAC,YAAA,EAAc,aAAa,CAAC;AAGpE,MAAM,sBAAA,GAAyB;;;;;;;;;;;;;;;;;;;;;;;;"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var node_crypto = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
const TABLE = "lightspeed_mcp_user_settings";
|
|
6
|
+
class McpUserSettingsStore {
|
|
7
|
+
constructor(db, encryptor, logger) {
|
|
8
|
+
this.db = db;
|
|
9
|
+
this.encryptor = encryptor;
|
|
10
|
+
this.logger = logger;
|
|
11
|
+
}
|
|
12
|
+
/** List all settings for a specific user. */
|
|
13
|
+
async listByUser(userEntityRef) {
|
|
14
|
+
const rows = await this.db(TABLE).where({ user_entity_ref: userEntityRef }).select("*");
|
|
15
|
+
return Promise.all(rows.map((r) => this.decryptRow(r)));
|
|
16
|
+
}
|
|
17
|
+
/** Get settings for a specific server + user combination. */
|
|
18
|
+
async get(serverName, userEntityRef) {
|
|
19
|
+
const row = await this.db(TABLE).where({ server_name: serverName, user_entity_ref: userEntityRef }).first();
|
|
20
|
+
return row ? this.decryptRow(row) : void 0;
|
|
21
|
+
}
|
|
22
|
+
async decryptRow(row) {
|
|
23
|
+
if (!row.token) {
|
|
24
|
+
return row;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const result = this.encryptor.decrypt(row.token);
|
|
28
|
+
if (result.needsReEncrypt && result.plaintext) {
|
|
29
|
+
await this.reEncryptToken(
|
|
30
|
+
row.server_name,
|
|
31
|
+
row.user_entity_ref,
|
|
32
|
+
result.plaintext
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return { ...row, token: result.plaintext };
|
|
36
|
+
} catch (err) {
|
|
37
|
+
this.logger.error(
|
|
38
|
+
`Failed to decrypt token for ${row.server_name}/${row.user_entity_ref} \u2014 treating as missing`,
|
|
39
|
+
err instanceof Error ? err : void 0
|
|
40
|
+
);
|
|
41
|
+
return { ...row, token: null };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async reEncryptToken(serverName, userEntityRef, plaintext) {
|
|
45
|
+
try {
|
|
46
|
+
const encrypted = this.encryptor.encrypt(plaintext);
|
|
47
|
+
await this.db(TABLE).where({ server_name: serverName, user_entity_ref: userEntityRef }).update({ token: encrypted, updated_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
48
|
+
this.logger.info(
|
|
49
|
+
`Re-encrypted token for ${serverName} with the current primary key`
|
|
50
|
+
);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
this.logger.warn(
|
|
53
|
+
`Failed to re-encrypt token for ${serverName} \u2014 will retry on next read`,
|
|
54
|
+
err instanceof Error ? err : void 0
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Create or update user settings for a server (atomic). */
|
|
59
|
+
async upsert(serverName, userEntityRef, updates) {
|
|
60
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
61
|
+
const encryptedToken = updates.token ? this.encryptor.encrypt(updates.token) : updates.token ?? null;
|
|
62
|
+
const row = {
|
|
63
|
+
id: node_crypto.randomUUID(),
|
|
64
|
+
server_name: serverName,
|
|
65
|
+
user_entity_ref: userEntityRef,
|
|
66
|
+
enabled: updates.enabled ?? true,
|
|
67
|
+
token: encryptedToken,
|
|
68
|
+
status: "unknown",
|
|
69
|
+
tool_count: 0,
|
|
70
|
+
created_at: now,
|
|
71
|
+
updated_at: now
|
|
72
|
+
};
|
|
73
|
+
const mergeFields = { updated_at: now };
|
|
74
|
+
if (updates.enabled !== void 0) mergeFields.enabled = updates.enabled;
|
|
75
|
+
if (updates.token !== void 0) {
|
|
76
|
+
mergeFields.token = encryptedToken;
|
|
77
|
+
mergeFields.status = "unknown";
|
|
78
|
+
mergeFields.tool_count = 0;
|
|
79
|
+
}
|
|
80
|
+
await this.db(TABLE).insert(row).onConflict(["server_name", "user_entity_ref"]).merge(mergeFields);
|
|
81
|
+
const result = await this.get(serverName, userEntityRef);
|
|
82
|
+
if (!result) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Failed to upsert settings for ${serverName}/${userEntityRef}`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
/** Update cached validation status for a user's server setting (atomic). */
|
|
90
|
+
async updateStatus(serverName, userEntityRef, status, toolCount) {
|
|
91
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
92
|
+
await this.db(TABLE).insert({
|
|
93
|
+
id: node_crypto.randomUUID(),
|
|
94
|
+
server_name: serverName,
|
|
95
|
+
user_entity_ref: userEntityRef,
|
|
96
|
+
enabled: true,
|
|
97
|
+
token: null,
|
|
98
|
+
status,
|
|
99
|
+
tool_count: toolCount,
|
|
100
|
+
created_at: now,
|
|
101
|
+
updated_at: now
|
|
102
|
+
}).onConflict(["server_name", "user_entity_ref"]).merge({ status, tool_count: toolCount, updated_at: now });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
exports.McpUserSettingsStore = McpUserSettingsStore;
|
|
107
|
+
//# sourceMappingURL=mcp-server-store.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp-server-store.cjs.js","sources":["../../src/service/mcp-server-store.ts"],"sourcesContent":["/*\n * Copyright Red Hat, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { LoggerService } from '@backstage/backend-plugin-api';\n\nimport { Knex } from 'knex';\n\nimport { randomUUID } from 'node:crypto';\n\nimport { McpServerStatus, McpUserSettingsRow } from './mcp-server-types';\nimport { TokenEncryptor } from './token-encryption';\n\nconst TABLE = 'lightspeed_mcp_user_settings';\n\n/**\n * Stores per-user preferences for admin-configured MCP servers.\n *\n * Each row represents one user's settings for one static MCP server:\n * enabled/disabled toggle, optional personal token override, and\n * cached validation status.\n *\n * Tokens are encrypted/decrypted transparently via the TokenEncryptor.\n */\nexport class McpUserSettingsStore {\n constructor(\n private readonly db: Knex,\n private readonly encryptor: TokenEncryptor,\n private readonly logger: LoggerService,\n ) {}\n\n /** List all settings for a specific user. */\n async listByUser(userEntityRef: string): Promise<McpUserSettingsRow[]> {\n const rows = await this.db<McpUserSettingsRow>(TABLE)\n .where({ user_entity_ref: userEntityRef })\n .select('*');\n return Promise.all(rows.map(r => this.decryptRow(r)));\n }\n\n /** Get settings for a specific server + user combination. */\n async get(\n serverName: string,\n userEntityRef: string,\n ): Promise<McpUserSettingsRow | undefined> {\n const row = await this.db<McpUserSettingsRow>(TABLE)\n .where({ server_name: serverName, user_entity_ref: userEntityRef })\n .first();\n return row ? this.decryptRow(row) : undefined;\n }\n\n private async decryptRow(\n row: McpUserSettingsRow,\n ): Promise<McpUserSettingsRow> {\n if (!row.token) {\n return row;\n }\n try {\n const result = this.encryptor.decrypt(row.token);\n if (result.needsReEncrypt && result.plaintext) {\n await this.reEncryptToken(\n row.server_name,\n row.user_entity_ref,\n result.plaintext,\n );\n }\n return { ...row, token: result.plaintext };\n } catch (err) {\n this.logger.error(\n `Failed to decrypt token for ${row.server_name}/${row.user_entity_ref} — treating as missing`,\n err instanceof Error ? err : undefined,\n );\n return { ...row, token: null };\n }\n }\n\n private async reEncryptToken(\n serverName: string,\n userEntityRef: string,\n plaintext: string,\n ): Promise<void> {\n try {\n const encrypted = this.encryptor.encrypt(plaintext);\n await this.db(TABLE)\n .where({ server_name: serverName, user_entity_ref: userEntityRef })\n .update({ token: encrypted, updated_at: new Date().toISOString() });\n this.logger.info(\n `Re-encrypted token for ${serverName} with the current primary key`,\n );\n } catch (err) {\n this.logger.warn(\n `Failed to re-encrypt token for ${serverName} — will retry on next read`,\n err instanceof Error ? err : undefined,\n );\n }\n }\n\n /** Create or update user settings for a server (atomic). */\n async upsert(\n serverName: string,\n userEntityRef: string,\n updates: { enabled?: boolean; token?: string | null },\n ): Promise<McpUserSettingsRow> {\n const now = new Date().toISOString();\n const encryptedToken = updates.token\n ? this.encryptor.encrypt(updates.token)\n : (updates.token ?? null);\n\n const row: McpUserSettingsRow = {\n id: randomUUID(),\n server_name: serverName,\n user_entity_ref: userEntityRef,\n enabled: updates.enabled ?? true,\n token: encryptedToken,\n status: 'unknown',\n tool_count: 0,\n created_at: now,\n updated_at: now,\n };\n\n const mergeFields: Partial<McpUserSettingsRow> = { updated_at: now };\n if (updates.enabled !== undefined) mergeFields.enabled = updates.enabled;\n if (updates.token !== undefined) {\n mergeFields.token = encryptedToken;\n mergeFields.status = 'unknown';\n mergeFields.tool_count = 0;\n }\n\n await this.db(TABLE)\n .insert(row)\n .onConflict(['server_name', 'user_entity_ref'])\n .merge(mergeFields);\n\n const result = await this.get(serverName, userEntityRef);\n if (!result) {\n throw new Error(\n `Failed to upsert settings for ${serverName}/${userEntityRef}`,\n );\n }\n return result;\n }\n\n /** Update cached validation status for a user's server setting (atomic). */\n async updateStatus(\n serverName: string,\n userEntityRef: string,\n status: McpServerStatus,\n toolCount: number,\n ): Promise<void> {\n const now = new Date().toISOString();\n\n await this.db(TABLE)\n .insert({\n id: randomUUID(),\n server_name: serverName,\n user_entity_ref: userEntityRef,\n enabled: true,\n token: null,\n status,\n tool_count: toolCount,\n created_at: now,\n updated_at: now,\n })\n .onConflict(['server_name', 'user_entity_ref'])\n .merge({ status, tool_count: toolCount, updated_at: now });\n }\n}\n"],"names":["randomUUID"],"mappings":"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"}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const REQUEST_TIMEOUT_MS = 1e4;
|
|
4
|
+
const INVALID_CREDENTIALS_ERROR = "Invalid credentials \u2014 server returned 401/403";
|
|
5
|
+
const getEndpointLabel = (targetUrl) => {
|
|
6
|
+
try {
|
|
7
|
+
const parsed = new URL(targetUrl);
|
|
8
|
+
return parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname;
|
|
9
|
+
} catch {
|
|
10
|
+
return targetUrl;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const getNestedError = (error) => {
|
|
14
|
+
if (error && typeof error === "object" && "cause" in error && error.cause instanceof Error) {
|
|
15
|
+
return error.cause;
|
|
16
|
+
}
|
|
17
|
+
return void 0;
|
|
18
|
+
};
|
|
19
|
+
const getNetworkErrorMessage = (url, error) => {
|
|
20
|
+
const endpoint = getEndpointLabel(url);
|
|
21
|
+
const nestedError = getNestedError(error);
|
|
22
|
+
const fullMessage = [
|
|
23
|
+
error instanceof Error ? error.message : "",
|
|
24
|
+
nestedError?.name ?? "",
|
|
25
|
+
nestedError?.message ?? ""
|
|
26
|
+
].filter(Boolean).join(" ").toLowerCase();
|
|
27
|
+
if (fullMessage.includes("timeout") || fullMessage.includes("aborterror") || fullMessage.includes("aborted")) {
|
|
28
|
+
return `Connection timed out while contacting ${endpoint}`;
|
|
29
|
+
}
|
|
30
|
+
if (fullMessage.includes("econnrefused") || fullMessage.includes("connection refused")) {
|
|
31
|
+
return `Connection refused by ${endpoint}`;
|
|
32
|
+
}
|
|
33
|
+
if (fullMessage.includes("enotfound") || fullMessage.includes("getaddrinfo")) {
|
|
34
|
+
return `Host not found for ${endpoint}`;
|
|
35
|
+
}
|
|
36
|
+
if (fullMessage.includes("econnreset") || fullMessage.includes("socket hang up")) {
|
|
37
|
+
return `Connection reset by ${endpoint}`;
|
|
38
|
+
}
|
|
39
|
+
if (fullMessage.includes("ehostunreach") || fullMessage.includes("enetunreach")) {
|
|
40
|
+
return `Host unreachable: ${endpoint}`;
|
|
41
|
+
}
|
|
42
|
+
if (fullMessage.includes("fetch failed")) {
|
|
43
|
+
return `Unable to connect to ${endpoint}`;
|
|
44
|
+
}
|
|
45
|
+
return nestedError?.message || (error instanceof Error ? error.message : String(error));
|
|
46
|
+
};
|
|
47
|
+
class McpServerValidator {
|
|
48
|
+
constructor(logger) {
|
|
49
|
+
this.logger = logger;
|
|
50
|
+
}
|
|
51
|
+
async validate(url, token) {
|
|
52
|
+
const trimmedToken = token.trim();
|
|
53
|
+
const hasAuthScheme = /^[A-Za-z][A-Za-z0-9_-]*\s+/.test(trimmedToken);
|
|
54
|
+
const authorizationHeaders = hasAuthScheme ? [trimmedToken] : [trimmedToken, `Bearer ${trimmedToken}`];
|
|
55
|
+
let lastResult = {
|
|
56
|
+
valid: false,
|
|
57
|
+
toolCount: 0,
|
|
58
|
+
tools: [],
|
|
59
|
+
error: INVALID_CREDENTIALS_ERROR
|
|
60
|
+
};
|
|
61
|
+
for (const [index, authorizationHeader] of authorizationHeaders.entries()) {
|
|
62
|
+
const result = await this.validateWithAuthorizationHeader(
|
|
63
|
+
url,
|
|
64
|
+
authorizationHeader
|
|
65
|
+
);
|
|
66
|
+
lastResult = result;
|
|
67
|
+
const isLastAttempt = index === authorizationHeaders.length - 1;
|
|
68
|
+
const shouldRetryWithAlternativeAuth = !isLastAttempt && !result.valid && result.error === INVALID_CREDENTIALS_ERROR;
|
|
69
|
+
if (!shouldRetryWithAlternativeAuth) {
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
this.logger.debug(
|
|
73
|
+
`MCP validation got 401/403 for ${url}; retrying with an alternate Authorization header format`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return lastResult;
|
|
77
|
+
}
|
|
78
|
+
async validateWithAuthorizationHeader(url, authorizationHeader) {
|
|
79
|
+
const headers = {
|
|
80
|
+
"Content-Type": "application/json",
|
|
81
|
+
Authorization: authorizationHeader,
|
|
82
|
+
Accept: "application/json, text/event-stream"
|
|
83
|
+
};
|
|
84
|
+
try {
|
|
85
|
+
const initResponse = await fetch(url, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers,
|
|
88
|
+
body: JSON.stringify({
|
|
89
|
+
jsonrpc: "2.0",
|
|
90
|
+
method: "initialize",
|
|
91
|
+
params: {
|
|
92
|
+
protocolVersion: "2024-11-05",
|
|
93
|
+
capabilities: {},
|
|
94
|
+
clientInfo: {
|
|
95
|
+
name: "intelligent-assistant-backend",
|
|
96
|
+
version: "1.0.0"
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
id: 1
|
|
100
|
+
}),
|
|
101
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
102
|
+
});
|
|
103
|
+
if (initResponse.status === 401 || initResponse.status === 403) {
|
|
104
|
+
return {
|
|
105
|
+
valid: false,
|
|
106
|
+
toolCount: 0,
|
|
107
|
+
tools: [],
|
|
108
|
+
error: INVALID_CREDENTIALS_ERROR
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (!initResponse.ok) {
|
|
112
|
+
return {
|
|
113
|
+
valid: false,
|
|
114
|
+
toolCount: 0,
|
|
115
|
+
tools: [],
|
|
116
|
+
error: `Server returned HTTP ${initResponse.status}`
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const sessionId = initResponse.headers.get("mcp-session-id");
|
|
120
|
+
if (sessionId) {
|
|
121
|
+
headers["Mcp-Session-Id"] = sessionId;
|
|
122
|
+
}
|
|
123
|
+
await fetch(url, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers,
|
|
126
|
+
body: JSON.stringify({
|
|
127
|
+
jsonrpc: "2.0",
|
|
128
|
+
method: "notifications/initialized"
|
|
129
|
+
}),
|
|
130
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
131
|
+
}).catch((err) => {
|
|
132
|
+
this.logger.debug(
|
|
133
|
+
`MCP initialized notification failed: ${err instanceof Error ? err.message : String(err)}`
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
const toolsResponse = await fetch(url, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers,
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
jsonrpc: "2.0",
|
|
141
|
+
method: "tools/list",
|
|
142
|
+
id: 2
|
|
143
|
+
}),
|
|
144
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
145
|
+
});
|
|
146
|
+
if (toolsResponse.ok) {
|
|
147
|
+
const contentType = toolsResponse.headers.get("content-type") || "";
|
|
148
|
+
let rpcResult;
|
|
149
|
+
if (contentType.includes("application/json")) {
|
|
150
|
+
const data = await toolsResponse.json();
|
|
151
|
+
rpcResult = data.result;
|
|
152
|
+
} else if (contentType.includes("text/event-stream")) {
|
|
153
|
+
rpcResult = await this.parseToolsFromSse(toolsResponse);
|
|
154
|
+
}
|
|
155
|
+
if (rpcResult) {
|
|
156
|
+
const tools = rpcResult.tools ?? [];
|
|
157
|
+
return {
|
|
158
|
+
valid: true,
|
|
159
|
+
toolCount: tools.length,
|
|
160
|
+
tools: tools.map((t) => ({
|
|
161
|
+
name: t.name,
|
|
162
|
+
description: t.description ?? ""
|
|
163
|
+
}))
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return { valid: true, toolCount: 0, tools: [] };
|
|
167
|
+
}
|
|
168
|
+
this.logger.warn(
|
|
169
|
+
`MCP server at ${url} accepted initialize but tools/list returned ${toolsResponse.status}`
|
|
170
|
+
);
|
|
171
|
+
return { valid: true, toolCount: 0, tools: [] };
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const message = getNetworkErrorMessage(url, error);
|
|
174
|
+
this.logger.error(`MCP validation failed for ${url}: ${message}`);
|
|
175
|
+
return {
|
|
176
|
+
valid: false,
|
|
177
|
+
toolCount: 0,
|
|
178
|
+
tools: [],
|
|
179
|
+
error: message
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Parse a tools/list JSON-RPC result from an SSE (text/event-stream) response.
|
|
185
|
+
* MCP Streamable HTTP servers may return SSE instead of plain JSON.
|
|
186
|
+
* Each SSE event has the form:
|
|
187
|
+
* event: message
|
|
188
|
+
* data: {"jsonrpc":"2.0","result":{...},"id":2}
|
|
189
|
+
*/
|
|
190
|
+
async parseToolsFromSse(response) {
|
|
191
|
+
try {
|
|
192
|
+
const body = await response.text();
|
|
193
|
+
for (const line of body.split("\n")) {
|
|
194
|
+
const trimmed = line.trim();
|
|
195
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
196
|
+
const jsonStr = trimmed.slice("data:".length).trim();
|
|
197
|
+
if (!jsonStr) continue;
|
|
198
|
+
try {
|
|
199
|
+
const parsed = JSON.parse(jsonStr);
|
|
200
|
+
if (parsed.result) {
|
|
201
|
+
return parsed.result;
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} catch (error) {
|
|
207
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
208
|
+
this.logger.warn(`Failed to parse SSE tools/list response: ${msg}`);
|
|
209
|
+
}
|
|
210
|
+
return void 0;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
exports.McpServerValidator = McpServerValidator;
|
|
215
|
+
//# sourceMappingURL=mcp-server-validator.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp-server-validator.cjs.js","sources":["../../src/service/mcp-server-validator.ts"],"sourcesContent":["/*\n * Copyright Red Hat, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { LoggerService } from '@backstage/backend-plugin-api';\n\nimport { McpValidationResult } from './mcp-server-types';\n\nconst REQUEST_TIMEOUT_MS = 10_000;\nconst INVALID_CREDENTIALS_ERROR =\n 'Invalid credentials — server returned 401/403';\n\nconst getEndpointLabel = (targetUrl: string): string => {\n try {\n const parsed = new URL(targetUrl);\n return parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname;\n } catch {\n return targetUrl;\n }\n};\n\nconst getNestedError = (error: unknown): Error | undefined => {\n if (\n error &&\n typeof error === 'object' &&\n 'cause' in error &&\n (error as { cause?: unknown }).cause instanceof Error\n ) {\n return (error as { cause: Error }).cause;\n }\n return undefined;\n};\n\nconst getNetworkErrorMessage = (url: string, error: unknown): string => {\n const endpoint = getEndpointLabel(url);\n const nestedError = getNestedError(error);\n const fullMessage = [\n error instanceof Error ? error.message : '',\n nestedError?.name ?? '',\n nestedError?.message ?? '',\n ]\n .filter(Boolean)\n .join(' ')\n .toLowerCase();\n\n if (\n fullMessage.includes('timeout') ||\n fullMessage.includes('aborterror') ||\n fullMessage.includes('aborted')\n ) {\n return `Connection timed out while contacting ${endpoint}`;\n }\n if (\n fullMessage.includes('econnrefused') ||\n fullMessage.includes('connection refused')\n ) {\n return `Connection refused by ${endpoint}`;\n }\n if (\n fullMessage.includes('enotfound') ||\n fullMessage.includes('getaddrinfo')\n ) {\n return `Host not found for ${endpoint}`;\n }\n if (\n fullMessage.includes('econnreset') ||\n fullMessage.includes('socket hang up')\n ) {\n return `Connection reset by ${endpoint}`;\n }\n if (\n fullMessage.includes('ehostunreach') ||\n fullMessage.includes('enetunreach')\n ) {\n return `Host unreachable: ${endpoint}`;\n }\n if (fullMessage.includes('fetch failed')) {\n return `Unable to connect to ${endpoint}`;\n }\n\n return (\n nestedError?.message ||\n (error instanceof Error ? error.message : String(error))\n );\n};\n\n/**\n * Validates MCP server credentials using the Streamable HTTP transport.\n *\n * The flow follows the MCP protocol:\n * 1. POST initialize → server returns capabilities + session id\n * 2. POST notifications/initialized\n * 3. POST tools/list → server returns available tools\n */\nexport class McpServerValidator {\n constructor(private readonly logger: LoggerService) {}\n\n async validate(url: string, token: string): Promise<McpValidationResult> {\n const trimmedToken = token.trim();\n const hasAuthScheme = /^[A-Za-z][A-Za-z0-9_-]*\\s+/.test(trimmedToken);\n const authorizationHeaders = hasAuthScheme\n ? [trimmedToken]\n : [trimmedToken, `Bearer ${trimmedToken}`];\n\n let lastResult: McpValidationResult = {\n valid: false,\n toolCount: 0,\n tools: [],\n error: INVALID_CREDENTIALS_ERROR,\n };\n\n for (const [index, authorizationHeader] of authorizationHeaders.entries()) {\n const result = await this.validateWithAuthorizationHeader(\n url,\n authorizationHeader,\n );\n lastResult = result;\n\n const isLastAttempt = index === authorizationHeaders.length - 1;\n const shouldRetryWithAlternativeAuth =\n !isLastAttempt &&\n !result.valid &&\n result.error === INVALID_CREDENTIALS_ERROR;\n\n if (!shouldRetryWithAlternativeAuth) {\n return result;\n }\n\n this.logger.debug(\n `MCP validation got 401/403 for ${url}; retrying with an alternate Authorization header format`,\n );\n }\n\n return lastResult;\n }\n\n private async validateWithAuthorizationHeader(\n url: string,\n authorizationHeader: string,\n ): Promise<McpValidationResult> {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n Authorization: authorizationHeader,\n Accept: 'application/json, text/event-stream',\n };\n\n try {\n // Step 1: Initialize\n const initResponse = await fetch(url, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n jsonrpc: '2.0',\n method: 'initialize',\n params: {\n protocolVersion: '2024-11-05',\n capabilities: {},\n clientInfo: {\n name: 'intelligent-assistant-backend',\n version: '1.0.0',\n },\n },\n id: 1,\n }),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n });\n\n if (initResponse.status === 401 || initResponse.status === 403) {\n return {\n valid: false,\n toolCount: 0,\n tools: [],\n error: INVALID_CREDENTIALS_ERROR,\n };\n }\n\n if (!initResponse.ok) {\n return {\n valid: false,\n toolCount: 0,\n tools: [],\n error: `Server returned HTTP ${initResponse.status}`,\n };\n }\n\n const sessionId = initResponse.headers.get('mcp-session-id');\n if (sessionId) {\n headers['Mcp-Session-Id'] = sessionId;\n }\n\n // Step 2: Send initialized notification\n await fetch(url, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n jsonrpc: '2.0',\n method: 'notifications/initialized',\n }),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n }).catch((err: unknown) => {\n this.logger.debug(\n `MCP initialized notification failed: ${err instanceof Error ? err.message : String(err)}`,\n );\n });\n\n // Step 3: List tools\n const toolsResponse = await fetch(url, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n jsonrpc: '2.0',\n method: 'tools/list',\n id: 2,\n }),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n });\n\n if (toolsResponse.ok) {\n const contentType = toolsResponse.headers.get('content-type') || '';\n\n let rpcResult:\n | {\n tools?: Array<{ name: string; description?: string }>;\n }\n | undefined;\n\n if (contentType.includes('application/json')) {\n const data = (await toolsResponse.json()) as {\n result?: typeof rpcResult;\n };\n rpcResult = data.result;\n } else if (contentType.includes('text/event-stream')) {\n rpcResult = await this.parseToolsFromSse(toolsResponse);\n }\n\n if (rpcResult) {\n const tools = rpcResult.tools ?? [];\n return {\n valid: true,\n toolCount: tools.length,\n tools: tools.map(t => ({\n name: t.name,\n description: t.description ?? '',\n })),\n };\n }\n\n // Server responded with an unexpected content-type — still valid\n return { valid: true, toolCount: 0, tools: [] };\n }\n\n // Initialize succeeded but tools/list failed — still consider connected\n this.logger.warn(\n `MCP server at ${url} accepted initialize but tools/list returned ${toolsResponse.status}`,\n );\n return { valid: true, toolCount: 0, tools: [] };\n } catch (error: unknown) {\n const message = getNetworkErrorMessage(url, error);\n\n this.logger.error(`MCP validation failed for ${url}: ${message}`);\n return {\n valid: false,\n toolCount: 0,\n tools: [],\n error: message,\n };\n }\n }\n\n /**\n * Parse a tools/list JSON-RPC result from an SSE (text/event-stream) response.\n * MCP Streamable HTTP servers may return SSE instead of plain JSON.\n * Each SSE event has the form:\n * event: message\n * data: {\"jsonrpc\":\"2.0\",\"result\":{...},\"id\":2}\n */\n private async parseToolsFromSse(\n response: Response,\n ): Promise<\n { tools?: Array<{ name: string; description?: string }> } | undefined\n > {\n try {\n const body = await response.text();\n\n for (const line of body.split('\\n')) {\n const trimmed = line.trim();\n if (!trimmed.startsWith('data:')) continue;\n\n const jsonStr = trimmed.slice('data:'.length).trim();\n if (!jsonStr) continue;\n\n try {\n const parsed = JSON.parse(jsonStr) as {\n result?: {\n tools?: Array<{ name: string; description?: string }>;\n };\n };\n if (parsed.result) {\n return parsed.result;\n }\n } catch {\n // not valid JSON — skip this data line\n }\n }\n } catch (error) {\n const msg = error instanceof Error ? error.message : String(error);\n this.logger.warn(`Failed to parse SSE tools/list response: ${msg}`);\n }\n return undefined;\n }\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var errors = require('@backstage/errors');
|
|
4
|
+
var getIdentity = require('./getIdentity.cjs.js');
|
|
5
|
+
|
|
6
|
+
function createPermissionMiddleware(authorizer, permission, logger) {
|
|
7
|
+
return async (req, res, next) => {
|
|
8
|
+
try {
|
|
9
|
+
const { credentials } = getIdentity.getIdentity(req);
|
|
10
|
+
await authorizer.authorizeUser(permission, credentials);
|
|
11
|
+
return next();
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (error instanceof errors.NotAllowedError) {
|
|
14
|
+
logger.error(error.message);
|
|
15
|
+
return res.status(403).json({ error: error.message });
|
|
16
|
+
}
|
|
17
|
+
logger.error(`Unexpected authorization error: ${error}`);
|
|
18
|
+
return res.status(500).json({ error: "Internal authorization error" });
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
exports.createPermissionMiddleware = createPermissionMiddleware;
|
|
24
|
+
//# sourceMappingURL=createPermissionMiddleware.cjs.js.map
|