dsh-mcp-connector 0.1.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 +25 -0
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/catalog/catalog.json +162 -0
- package/catalog/catalog.sample.json +166 -0
- package/cordis.patch.yml +12 -0
- package/install.sh +64 -0
- package/lib/callback-server.js +92 -0
- package/lib/catalog.js +88 -0
- package/lib/client.js +366 -0
- package/lib/connectors/json-connector.js +137 -0
- package/lib/connectors/manual-connector.js +63 -0
- package/lib/connectors/oauth-connector.js +92 -0
- package/lib/constants.js +30 -0
- package/lib/index.js +613 -0
- package/lib/mcp-provision.js +83 -0
- package/lib/oauth.js +321 -0
- package/lib/schema.js +151 -0
- package/lib/stores.js +110 -0
- package/lib/tools.js +293 -0
- package/lib/util.js +73 -0
- package/lib/web.js +218 -0
- package/package.json +87 -0
- package/ui/assets/qcc-logo.svg +3 -0
- package/ui/index.html +753 -0
package/lib/catalog.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 连接器目录:内置 + 远程 registry + config 注入 + 本地覆盖(上架/下架开关)。
|
|
3
|
+
* 合并键为 id,优先级:本地覆盖 > config.connectors > 远程 registry > 内置目录。
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { normalizeConnectorDescriptor } from './schema.js';
|
|
7
|
+
import { assertSafeUrl, assertSafeHeaderName } from './util.js';
|
|
8
|
+
|
|
9
|
+
/** 读取包内内置目录 */
|
|
10
|
+
export function loadBundledCatalog() {
|
|
11
|
+
try {
|
|
12
|
+
const raw = readFileSync(new URL('../catalog/catalog.json', import.meta.url), 'utf8');
|
|
13
|
+
const data = JSON.parse(raw);
|
|
14
|
+
const list = Array.isArray(data) ? data : Array.isArray(data?.connectors) ? data.connectors : [];
|
|
15
|
+
return list.map(normalizeConnectorDescriptor);
|
|
16
|
+
} catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 安全审计:目录禁止携带密钥、URL 协议白名单、header 名白名单。 */
|
|
22
|
+
export function auditDescriptor(descriptor) {
|
|
23
|
+
for (const server of descriptor.servers) {
|
|
24
|
+
assertSafeUrl(server.url);
|
|
25
|
+
for (const name of Object.keys(server.headers ?? {})) {
|
|
26
|
+
assertSafeHeaderName(name);
|
|
27
|
+
if (/authorization|token|secret|api[-_]?key/i.test(name)) {
|
|
28
|
+
throw new Error(`connector "${descriptor.id}" servers[].headers 禁止携带密钥类头: ${name}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return descriptor;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 拉取远程 registry(HTTPS + TTL 缓存 + 可选 ETag) */
|
|
36
|
+
export async function fetchRemoteCatalog(catalogUrl, { requestTimeoutMs, etag } = {}) {
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timer = setTimeout(() => controller.abort(new Error('timeout')), requestTimeoutMs ?? 15_000);
|
|
39
|
+
try {
|
|
40
|
+
const headers = {};
|
|
41
|
+
if (etag) headers['If-None-Match'] = etag;
|
|
42
|
+
const response = await fetch(catalogUrl, { headers, signal: controller.signal });
|
|
43
|
+
if (response.status === 304) return { notModified: true };
|
|
44
|
+
if (!response.ok) throw new Error(`catalog fetch HTTP ${response.status}`);
|
|
45
|
+
const data = await response.json();
|
|
46
|
+
const list = Array.isArray(data) ? data : Array.isArray(data?.connectors) ? data.connectors : [];
|
|
47
|
+
const connectors = list.map((raw) => auditDescriptor(normalizeConnectorDescriptor(raw)));
|
|
48
|
+
return { notModified: false, etag: response.headers.get('etag') ?? undefined, connectors };
|
|
49
|
+
} finally {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 合并多层来源(sources 为低→高优先级的 descriptor 数组),再应用本地覆盖。 */
|
|
55
|
+
export function mergeCatalog(sources, overrides = new Map()) {
|
|
56
|
+
const merged = new Map();
|
|
57
|
+
for (const list of sources) {
|
|
58
|
+
for (const descriptor of list) {
|
|
59
|
+
merged.set(descriptor.id, descriptor);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const [id, patch] of overrides) {
|
|
63
|
+
const current = merged.get(id);
|
|
64
|
+
if (!current) continue;
|
|
65
|
+
const next = { ...current };
|
|
66
|
+
if (typeof patch.published === 'boolean') next.published = patch.published;
|
|
67
|
+
if (typeof patch.featured === 'boolean') next.featured = patch.featured;
|
|
68
|
+
merged.set(id, next);
|
|
69
|
+
}
|
|
70
|
+
return [...merged.values()];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 目录查询(publishedOnly 默认只显示已上架) */
|
|
74
|
+
export function listCatalog(merged, { category, keyword, publishedOnly = true } = {}) {
|
|
75
|
+
let list = merged;
|
|
76
|
+
if (publishedOnly) list = list.filter((d) => d.published !== false);
|
|
77
|
+
if (category) list = list.filter((d) => d.category === category);
|
|
78
|
+
if (keyword) {
|
|
79
|
+
const kw = String(keyword).toLowerCase();
|
|
80
|
+
list = list.filter((d) =>
|
|
81
|
+
[d.id, d.name, d.vendor, d.category, d.summary, d.description, ...(d.tags ?? [])]
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.some((v) => String(v).toLowerCase().includes(kw)),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
// 精选位优先,其余按 id 排序保持稳定
|
|
87
|
+
return [...list].sort((a, b) => (b.featured === true) - (a.featured === true) || a.id.localeCompare(b.id));
|
|
88
|
+
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-mcp-connector",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
8
|
+
let react = require("react");
|
|
9
|
+
let react_dom = require("react-dom");
|
|
10
|
+
let _deepseek_ai_dsh_client_runtime_client = require("@deepseek-ai/dsh-client-runtime/client");
|
|
11
|
+
let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
12
|
+
|
|
13
|
+
/** 客户端所需服务:槽位、工作区、会话及对话输入机。 */
|
|
14
|
+
const inject = [
|
|
15
|
+
"slots",
|
|
16
|
+
"sessions",
|
|
17
|
+
"workspaces",
|
|
18
|
+
"conversation"
|
|
19
|
+
];
|
|
20
|
+
const PROMPT_REQUEST_TYPE = "mcp-connector:start-session";
|
|
21
|
+
const PROMPT_RESULT_TYPE = "mcp-connector:start-session-result";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Desktop 内置的社区插件市场也注册在 sidebar.footer.action。
|
|
25
|
+
* 该 list slot 的宿主容器默认横排,会把两个本应占满侧边栏宽度的
|
|
26
|
+
* launcher 挤在同一行。覆盖 SlotOutlet 的 display: contents,让所有
|
|
27
|
+
* footer action 在 Web 与 Desktop 中都按独立行纵向排列。
|
|
28
|
+
*/
|
|
29
|
+
const SIDEBAR_STYLE_ID = "dsh-mcp-connector-sidebar";
|
|
30
|
+
const sidebarCss = `
|
|
31
|
+
[data-slot="sidebar.footer.action"] {
|
|
32
|
+
display: flex !important;
|
|
33
|
+
flex-direction: column;
|
|
34
|
+
min-width: 0;
|
|
35
|
+
width: 100%;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.mcpConnectorLauncher {
|
|
39
|
+
flex: none;
|
|
40
|
+
box-sizing: border-box;
|
|
41
|
+
width: calc(100% + 4px);
|
|
42
|
+
height: 42px;
|
|
43
|
+
margin: 4px -2px;
|
|
44
|
+
padding: 0 10px 0 8px;
|
|
45
|
+
justify-content: flex-start;
|
|
46
|
+
overflow: hidden;
|
|
47
|
+
border-radius: 12px;
|
|
48
|
+
white-space: nowrap;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.mcpConnectorLauncher[data-wide="false"] {
|
|
52
|
+
width: 36px;
|
|
53
|
+
height: 36px;
|
|
54
|
+
margin: 8px 0 10px;
|
|
55
|
+
padding: 0;
|
|
56
|
+
justify-content: center;
|
|
57
|
+
border-radius: 50%;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.mcpConnectorTopMount {
|
|
61
|
+
flex: none;
|
|
62
|
+
min-width: 0;
|
|
63
|
+
width: 100%;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.mcpConnectorTopEntry {
|
|
67
|
+
box-sizing: border-box;
|
|
68
|
+
width: 100%;
|
|
69
|
+
padding-right: var(--dsh-sidebar-inline-padding, 12px);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.mcpConnectorTopEntry .mcpConnectorLauncher {
|
|
73
|
+
width: 100%;
|
|
74
|
+
margin: 0 0 8px;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.mcpConnectorTopEntry[data-wide="false"] {
|
|
78
|
+
width: 36px;
|
|
79
|
+
padding-right: 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.mcpConnectorTopEntry[data-wide="false"] .mcpConnectorLauncher {
|
|
83
|
+
margin: 0 0 8px;
|
|
84
|
+
}
|
|
85
|
+
`;
|
|
86
|
+
const SIDEBAR_WORKSPACES_SELECTOR = '[data-slot="sidebar.workspaces"]';
|
|
87
|
+
const TOP_MOUNT_SELECTOR = '[data-mcp-connector-top-mount="true"]';
|
|
88
|
+
|
|
89
|
+
function installSidebarStyles() {
|
|
90
|
+
if (document.querySelector(`style[data-plugin="${SIDEBAR_STYLE_ID}"]`) !== null) return () => {};
|
|
91
|
+
const style = document.createElement("style");
|
|
92
|
+
style.dataset.plugin = SIDEBAR_STYLE_ID;
|
|
93
|
+
style.textContent = sidebarCss;
|
|
94
|
+
document.head.append(style);
|
|
95
|
+
return () => { style.remove(); };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* DSH rc.7 没有公开的「新会话与工作区之间」插槽。插件仍注册在公开的
|
|
100
|
+
* footer list slot 中保证生命周期与降级可用,再把实际按钮 Portal 到
|
|
101
|
+
* sidebar.workspaces 前。只依赖稳定的 data-slot,不依赖构建生成的 CSS 类名。
|
|
102
|
+
*/
|
|
103
|
+
function ensureTopLauncherMount() {
|
|
104
|
+
const workspaceSlot = document.querySelector(SIDEBAR_WORKSPACES_SELECTOR);
|
|
105
|
+
const parent = workspaceSlot?.parentElement;
|
|
106
|
+
if (workspaceSlot === null || parent === null || parent === void 0) return null;
|
|
107
|
+
let mount = parent.querySelector(TOP_MOUNT_SELECTOR);
|
|
108
|
+
if (mount === null) {
|
|
109
|
+
mount = document.createElement("div");
|
|
110
|
+
mount.dataset.mcpConnectorTopMount = "true";
|
|
111
|
+
mount.className = "mcpConnectorTopMount";
|
|
112
|
+
}
|
|
113
|
+
if (mount.nextSibling !== workspaceSlot) parent.insertBefore(mount, workspaceSlot);
|
|
114
|
+
return mount;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 创建弹框 store(使用 DSH defineStore,与社区插件市场一致)。 */
|
|
118
|
+
function createMarketViewStore() {
|
|
119
|
+
return (0, _deepseek_ai_dsh_client_runtime_client.defineStore)({
|
|
120
|
+
init: () => ({ open: false, detailOpen: false }),
|
|
121
|
+
actions: {
|
|
122
|
+
open: (draft) => { draft.open = true; },
|
|
123
|
+
close: (draft) => { draft.open = false; draft.detailOpen = false; },
|
|
124
|
+
detailOpened: (draft) => { draft.detailOpen = true; },
|
|
125
|
+
detailClosed: (draft) => { draft.detailOpen = false; }
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 用 DSH 自身的工作区/会话/输入机把示例 Prompt 带入新会话。
|
|
132
|
+
* connectWorkspace 与 DSH 的「新会话」按钮同源:优先复用当前工作区的
|
|
133
|
+
* 空白会话,没有时创建一个;返回时 binding 已就绪,可在导航前写入草稿。
|
|
134
|
+
*/
|
|
135
|
+
async function startPromptSession(ctx, promptText) {
|
|
136
|
+
if (typeof promptText !== "string" || promptText.trim() === "") {
|
|
137
|
+
throw new Error("Prompt 不能为空");
|
|
138
|
+
}
|
|
139
|
+
const workspace = ctx.workspaces.list.getSnapshot();
|
|
140
|
+
const current = ctx.sessions.list.getSnapshot().current;
|
|
141
|
+
const currentWorkspaceId = current === void 0
|
|
142
|
+
? void 0
|
|
143
|
+
: workspace.items.find((item) => item.sessionIds.includes(current))?.workspaceId;
|
|
144
|
+
const targetWorkspaceId = currentWorkspaceId ?? workspace.recentWorkspaceId;
|
|
145
|
+
if (targetWorkspaceId === void 0) {
|
|
146
|
+
throw new Error("请先选择一个工作空间,再使用示例 Prompt");
|
|
147
|
+
}
|
|
148
|
+
const sessionId = await ctx.workspaces.connectWorkspace(targetWorkspaceId);
|
|
149
|
+
const conversation = ctx.get("conversation");
|
|
150
|
+
if (conversation === void 0) {
|
|
151
|
+
throw new Error("DSH 对话服务尚未就绪,请稍后重试");
|
|
152
|
+
}
|
|
153
|
+
conversation.input.shell(sessionId).setDraft(promptText);
|
|
154
|
+
ctx.sessions.open(sessionId);
|
|
155
|
+
return sessionId;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 弹框组件:居中显示市场 SPA(浅色主题)。 */
|
|
159
|
+
function MarketOverlay(props) {
|
|
160
|
+
const { wide, useStore, actions, startPromptSession: launchPromptSession } = props;
|
|
161
|
+
const open = useStore((state) => state.open);
|
|
162
|
+
const detailOpen = useStore((state) => state.detailOpen);
|
|
163
|
+
const frameRef = (0, react.useRef)(null);
|
|
164
|
+
(0, react.useEffect)(() => {
|
|
165
|
+
const onMessage = (event) => {
|
|
166
|
+
if (event.origin !== window.location.origin) return;
|
|
167
|
+
if (event.source !== frameRef.current?.contentWindow) return;
|
|
168
|
+
if (event.data?.type === "mcp-connector:detail-state") {
|
|
169
|
+
if (event.data.open === true) actions.detailOpened();
|
|
170
|
+
else actions.detailClosed();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (event.data?.type !== PROMPT_REQUEST_TYPE) return;
|
|
174
|
+
const requestId = typeof event.data.requestId === "string" ? event.data.requestId : "";
|
|
175
|
+
const prompt = event.data.prompt;
|
|
176
|
+
const reply = (ok, message) => {
|
|
177
|
+
frameRef.current?.contentWindow?.postMessage({
|
|
178
|
+
type: PROMPT_RESULT_TYPE,
|
|
179
|
+
requestId,
|
|
180
|
+
ok,
|
|
181
|
+
message
|
|
182
|
+
}, window.location.origin);
|
|
183
|
+
};
|
|
184
|
+
if (requestId === "" || typeof prompt !== "string") {
|
|
185
|
+
reply(false, "无效的 Prompt 请求");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
Promise.resolve(launchPromptSession(prompt)).then(() => {
|
|
189
|
+
reply(true, "已带入新会话");
|
|
190
|
+
window.requestAnimationFrame(() => { actions.close(); });
|
|
191
|
+
}, (error) => {
|
|
192
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
193
|
+
console.error("[mcp-connector] start prompt session failed:", error);
|
|
194
|
+
reply(false, message);
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
window.addEventListener("message", onMessage);
|
|
198
|
+
return () => { window.removeEventListener("message", onMessage); };
|
|
199
|
+
}, [actions, launchPromptSession]);
|
|
200
|
+
if (!open) return null;
|
|
201
|
+
const src = window.location.origin + "/mcp-connector/ui/";
|
|
202
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
203
|
+
style: {
|
|
204
|
+
position: "fixed",
|
|
205
|
+
inset: 0,
|
|
206
|
+
background: "rgba(0,0,0,0.4)",
|
|
207
|
+
display: "flex",
|
|
208
|
+
alignItems: "center",
|
|
209
|
+
justifyContent: "center",
|
|
210
|
+
zIndex: 1000
|
|
211
|
+
},
|
|
212
|
+
onClick: (event) => { if (event.target === event.currentTarget) actions.close(); },
|
|
213
|
+
children: [
|
|
214
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
215
|
+
style: {
|
|
216
|
+
background: "#ffffff",
|
|
217
|
+
borderRadius: 16,
|
|
218
|
+
width: "min(800px, 90%)",
|
|
219
|
+
height: "min(800px, 85%)",
|
|
220
|
+
display: "flex",
|
|
221
|
+
flexDirection: "column",
|
|
222
|
+
boxShadow: "0 20px 60px rgba(0,0,0,0.15)",
|
|
223
|
+
overflow: "hidden"
|
|
224
|
+
},
|
|
225
|
+
children: [
|
|
226
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
227
|
+
style: {
|
|
228
|
+
display: "flex",
|
|
229
|
+
alignItems: "center",
|
|
230
|
+
justifyContent: "space-between",
|
|
231
|
+
padding: "20px 24px",
|
|
232
|
+
borderBottom: "1px solid #e5e7eb",
|
|
233
|
+
flexShrink: 0,
|
|
234
|
+
filter: detailOpen ? "brightness(0.5)" : "none",
|
|
235
|
+
pointerEvents: detailOpen ? "none" : "auto",
|
|
236
|
+
transition: "filter 0.2s ease"
|
|
237
|
+
},
|
|
238
|
+
children: [
|
|
239
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
240
|
+
style: { display: "flex", alignItems: "center", gap: 10 },
|
|
241
|
+
children: [
|
|
242
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: { fontSize: 22 }, children: "\u{1F9E9}" }),
|
|
243
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: { fontSize: 18, fontWeight: 600, color: "#111827" }, children: "MCP连接器" })
|
|
244
|
+
]
|
|
245
|
+
}),
|
|
246
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
247
|
+
type: "button",
|
|
248
|
+
onClick: () => actions.close(),
|
|
249
|
+
style: {
|
|
250
|
+
background: "transparent",
|
|
251
|
+
border: "none",
|
|
252
|
+
color: "#6b7280",
|
|
253
|
+
fontSize: 22,
|
|
254
|
+
cursor: "pointer",
|
|
255
|
+
padding: "4px 8px",
|
|
256
|
+
borderRadius: 6,
|
|
257
|
+
lineHeight: 1
|
|
258
|
+
},
|
|
259
|
+
onMouseEnter: (e) => { e.currentTarget.style.background = "#f3f4f6"; },
|
|
260
|
+
onMouseLeave: (e) => { e.currentTarget.style.background = "transparent"; },
|
|
261
|
+
children: "\u00D7"
|
|
262
|
+
})
|
|
263
|
+
]
|
|
264
|
+
}),
|
|
265
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("iframe", {
|
|
266
|
+
ref: frameRef,
|
|
267
|
+
src,
|
|
268
|
+
title: "MCP连接器",
|
|
269
|
+
style: {
|
|
270
|
+
flex: 1,
|
|
271
|
+
width: "100%",
|
|
272
|
+
border: "none",
|
|
273
|
+
background: "#ffffff"
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
]
|
|
277
|
+
})
|
|
278
|
+
]
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 左栏入口按钮:使用 DSH Button 组件,与社区插件市场一致。 */
|
|
283
|
+
function SidebarEntry(props) {
|
|
284
|
+
const { wide, useStore, actions } = props;
|
|
285
|
+
const open = useStore((state) => state.open);
|
|
286
|
+
const [topMount, setTopMount] = (0, react.useState)(null);
|
|
287
|
+
(0, react.useEffect)(() => {
|
|
288
|
+
let disposed = false;
|
|
289
|
+
const ownedMounts = new Set();
|
|
290
|
+
const syncMount = () => {
|
|
291
|
+
if (disposed) return;
|
|
292
|
+
const mount = ensureTopLauncherMount();
|
|
293
|
+
if (mount !== null) ownedMounts.add(mount);
|
|
294
|
+
setTopMount((current) => current === mount ? current : mount);
|
|
295
|
+
};
|
|
296
|
+
syncMount();
|
|
297
|
+
let observer = null;
|
|
298
|
+
if (typeof window.MutationObserver === "function" && document.body !== null) {
|
|
299
|
+
observer = new window.MutationObserver(syncMount);
|
|
300
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
301
|
+
}
|
|
302
|
+
return () => {
|
|
303
|
+
disposed = true;
|
|
304
|
+
observer?.disconnect();
|
|
305
|
+
for (const mount of ownedMounts) mount.remove();
|
|
306
|
+
};
|
|
307
|
+
}, []);
|
|
308
|
+
const launcher = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
309
|
+
variant: "ghost",
|
|
310
|
+
className: "mcpConnectorLauncher",
|
|
311
|
+
"data-wide": wide,
|
|
312
|
+
"aria-label": "MCP连接器",
|
|
313
|
+
"aria-haspopup": "dialog",
|
|
314
|
+
"aria-expanded": open,
|
|
315
|
+
onClick: () => { try { actions.open(); } catch (error) { console.error('[mcp-connector] open failed:', error); } },
|
|
316
|
+
children: wide ? "🧩 MCP连接器" : "🧩"
|
|
317
|
+
});
|
|
318
|
+
if (topMount === null || typeof react_dom.createPortal !== "function") return launcher;
|
|
319
|
+
return react_dom.createPortal(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
320
|
+
className: "mcpConnectorTopEntry",
|
|
321
|
+
"data-wide": wide,
|
|
322
|
+
children: launcher
|
|
323
|
+
}), topMount);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function apply(ctx) {
|
|
327
|
+
console.log('[mcp-connector] client apply() called');
|
|
328
|
+
try {
|
|
329
|
+
const marketView = createMarketViewStore();
|
|
330
|
+
ctx.effect(() => installSidebarStyles(), "mcp-connector: sidebar styles");
|
|
331
|
+
|
|
332
|
+
// 弹框:注册到 shell.overlay(类似社区插件市场)
|
|
333
|
+
ctx.slots.inject("shell.overlay", () => {
|
|
334
|
+
console.log('[mcp-connector] registering shell.overlay slot');
|
|
335
|
+
return ctx.slots.register({
|
|
336
|
+
name: "shell.overlay",
|
|
337
|
+
id: "mcp-connector",
|
|
338
|
+
order: 100,
|
|
339
|
+
store: marketView,
|
|
340
|
+
inject: () => ({
|
|
341
|
+
startPromptSession: (promptText) => startPromptSession(ctx, promptText)
|
|
342
|
+
})
|
|
343
|
+
}, MarketOverlay);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// 左栏:公开 footer slot 托管生命周期;组件会 Portal 到工作区列表上方。
|
|
347
|
+
ctx.slots.inject("sidebar.footer.action", () => {
|
|
348
|
+
console.log('[mcp-connector] registering sidebar.footer.action slot');
|
|
349
|
+
return ctx.slots.register({
|
|
350
|
+
name: "sidebar.footer.action",
|
|
351
|
+
id: "mcp-connector",
|
|
352
|
+
order: 0,
|
|
353
|
+
store: marketView
|
|
354
|
+
}, SidebarEntry);
|
|
355
|
+
});
|
|
356
|
+
console.log('[mcp-connector] client apply() completed');
|
|
357
|
+
} catch (error) {
|
|
358
|
+
console.error('[mcp-connector] client apply() failed:', error);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
exports.apply = apply;
|
|
363
|
+
exports.inject = inject;
|
|
364
|
+
return module.exports;
|
|
365
|
+
}
|
|
366
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通道三:粘贴 JSON 配置(兼容 { mcpServers: {...} } 与 { connections: [...] })。
|
|
3
|
+
* 批量预校验,任一非法整体拒绝;stdio 类(command/args)显式跳过并提示。
|
|
4
|
+
*/
|
|
5
|
+
import { JSON_CONNECTOR_ID } from '../constants.js';
|
|
6
|
+
import { assertSafeUrl, assertSafeHeaderName, slugServerName } from '../util.js';
|
|
7
|
+
|
|
8
|
+
function parseInput(input) {
|
|
9
|
+
if (typeof input === 'string') {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(input);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
throw new Error(`JSON 解析失败: ${error.message}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return input;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function coerceHeaders(headers) {
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const [key, value] of Object.entries(headers ?? {})) out[assertSafeHeaderName(key)] = String(value);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 静态 headers 中的 Authorization: Bearer ... 提升为 auth.bearer,其余保留为静态头 */
|
|
26
|
+
function extractAuth(headers) {
|
|
27
|
+
const h = coerceHeaders(headers);
|
|
28
|
+
const authKey = Object.keys(h).find((k) => k.toLowerCase() === 'authorization');
|
|
29
|
+
if (authKey && /^Bearer\s+(.+)$/i.test(h[authKey])) {
|
|
30
|
+
const token = h[authKey].replace(/^Bearer\s+/i, '');
|
|
31
|
+
delete h[authKey];
|
|
32
|
+
return { auth: { mode: 'bearer', bearerToken: token }, headers: h };
|
|
33
|
+
}
|
|
34
|
+
return { auth: undefined, headers: h };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function authFromFields(item) {
|
|
38
|
+
const authMode = item.authMode ?? 'none';
|
|
39
|
+
if (authMode === 'bearer') {
|
|
40
|
+
return { mode: 'bearer', bearerToken: String(item.bearerToken ?? '') };
|
|
41
|
+
}
|
|
42
|
+
if (authMode === 'api-key') {
|
|
43
|
+
return {
|
|
44
|
+
mode: 'api-key',
|
|
45
|
+
apiKeyHeader: assertSafeHeaderName(item.apiKeyHeader ?? 'X-Api-Key'),
|
|
46
|
+
apiKeyValue: String(item.apiKeyValue ?? ''),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (authMode === 'none') return undefined;
|
|
50
|
+
throw new Error(`unsupported authMode: ${authMode}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {string|object} input
|
|
55
|
+
* @returns {{ records: object[], skipped: string[] }}
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeJsonImport(input) {
|
|
58
|
+
const obj = parseInput(input);
|
|
59
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) throw new Error('JSON 顶层必须是对象');
|
|
60
|
+
|
|
61
|
+
const records = [];
|
|
62
|
+
const skipped = [];
|
|
63
|
+
|
|
64
|
+
if (obj.mcpServers && typeof obj.mcpServers === 'object') {
|
|
65
|
+
for (const [name, cfg] of Object.entries(obj.mcpServers)) {
|
|
66
|
+
if (!cfg || typeof cfg !== 'object') {
|
|
67
|
+
skipped.push(`${name}(非法条目)`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (cfg.command || cfg.args) {
|
|
71
|
+
skipped.push(`${name}(stdio 暂不支持)`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (!cfg.url) {
|
|
75
|
+
skipped.push(`${name}(缺 url)`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const transport = cfg.transport ?? cfg.type ?? 'streamable-http';
|
|
79
|
+
const { auth, headers } = extractAuth(cfg.headers ?? cfg.httpHeaders);
|
|
80
|
+
records.push({
|
|
81
|
+
key: `json-${slugServerName(cfg.serverName ?? name)}`,
|
|
82
|
+
connectorId: JSON_CONNECTOR_ID,
|
|
83
|
+
kind: 'json',
|
|
84
|
+
name: cfg.name ?? name,
|
|
85
|
+
transport: transport === 'sse' ? 'sse' : 'streamable-http',
|
|
86
|
+
url: assertSafeUrl(cfg.url).toString(),
|
|
87
|
+
serverName: slugServerName(cfg.serverName ?? name),
|
|
88
|
+
headers,
|
|
89
|
+
auth,
|
|
90
|
+
enabled: true,
|
|
91
|
+
createdAt: Date.now(),
|
|
92
|
+
updatedAt: Date.now(),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
} else if (Array.isArray(obj.connections)) {
|
|
96
|
+
for (const item of obj.connections) {
|
|
97
|
+
if (!item || typeof item !== 'object') {
|
|
98
|
+
skipped.push('(非法条目)');
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (item.command || item.args) {
|
|
102
|
+
skipped.push(`${item.name ?? '(未命名)'}(stdio 暂不支持)`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!item.url) {
|
|
106
|
+
skipped.push(`${item.name ?? '(未命名)'}(缺 url)`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const name = String(item.name ?? item.serverName ?? '');
|
|
110
|
+
if (!name) {
|
|
111
|
+
skipped.push('(缺 name/serverName)');
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
records.push({
|
|
115
|
+
key: `json-${slugServerName(item.serverName ?? name)}`,
|
|
116
|
+
connectorId: JSON_CONNECTOR_ID,
|
|
117
|
+
kind: 'json',
|
|
118
|
+
name,
|
|
119
|
+
transport: item.transport === 'sse' ? 'sse' : 'streamable-http',
|
|
120
|
+
url: assertSafeUrl(item.url).toString(),
|
|
121
|
+
serverName: slugServerName(item.serverName ?? name),
|
|
122
|
+
headers: coerceHeaders(item.headers),
|
|
123
|
+
auth: authFromFields(item),
|
|
124
|
+
enabled: true,
|
|
125
|
+
createdAt: Date.now(),
|
|
126
|
+
updatedAt: Date.now(),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
throw new Error('不支持的 JSON 格式:期望 { mcpServers: {...} } 或 { connections: [...] }');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (records.length === 0) {
|
|
134
|
+
throw new Error(`没有可导入的连接${skipped.length ? `(跳过:${skipped.join('、')})` : ''}`);
|
|
135
|
+
}
|
|
136
|
+
return { records, skipped };
|
|
137
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通道二:自定义配置连接(表单式填 transport/url/serverName/鉴权)。
|
|
3
|
+
*/
|
|
4
|
+
import { CUSTOM_CONNECTOR_ID } from '../constants.js';
|
|
5
|
+
import { assertSafeUrl, assertSafeHeaderName, slugServerName } from '../util.js';
|
|
6
|
+
|
|
7
|
+
function parseHeadersJson(headersJson) {
|
|
8
|
+
if (headersJson === undefined || headersJson === null || headersJson === '') return {};
|
|
9
|
+
let obj;
|
|
10
|
+
if (typeof headersJson === 'string') {
|
|
11
|
+
try {
|
|
12
|
+
obj = JSON.parse(headersJson);
|
|
13
|
+
} catch {
|
|
14
|
+
throw new Error('headersJson 不是合法 JSON');
|
|
15
|
+
}
|
|
16
|
+
} else {
|
|
17
|
+
obj = headersJson;
|
|
18
|
+
}
|
|
19
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) throw new Error('headersJson 必须是 JSON 对象');
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const [key, value] of Object.entries(obj)) out[assertSafeHeaderName(key)] = String(value);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} params { name, url, transport?, serverName?, authMode?, bearerToken?, apiKeyHeader?, apiKeyValue?, headersJson? }
|
|
27
|
+
*/
|
|
28
|
+
export function buildManualRecord(params = {}) {
|
|
29
|
+
const name = String(params.name ?? '').trim();
|
|
30
|
+
if (!name) throw new Error('name 必填');
|
|
31
|
+
const url = assertSafeUrl(params.url).toString();
|
|
32
|
+
const serverName = slugServerName(params.serverName ?? name);
|
|
33
|
+
const transport = params.transport === 'sse' ? 'sse' : 'streamable-http';
|
|
34
|
+
const headers = parseHeadersJson(params.headersJson);
|
|
35
|
+
|
|
36
|
+
const authMode = params.authMode ?? 'none';
|
|
37
|
+
let auth;
|
|
38
|
+
if (authMode === 'bearer') {
|
|
39
|
+
if (!params.bearerToken) throw new Error('authMode=bearer 时 bearerToken 必填');
|
|
40
|
+
auth = { mode: 'bearer', bearerToken: String(params.bearerToken) };
|
|
41
|
+
} else if (authMode === 'api-key') {
|
|
42
|
+
const apiKeyHeader = assertSafeHeaderName(params.apiKeyHeader ?? 'X-Api-Key');
|
|
43
|
+
if (params.apiKeyValue === undefined || params.apiKeyValue === '') throw new Error('authMode=api-key 时 apiKeyValue 必填');
|
|
44
|
+
auth = { mode: 'api-key', apiKeyHeader, apiKeyValue: String(params.apiKeyValue) };
|
|
45
|
+
} else if (authMode !== 'none') {
|
|
46
|
+
throw new Error(`unsupported authMode: ${authMode}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
key: `custom-${serverName}`,
|
|
51
|
+
connectorId: CUSTOM_CONNECTOR_ID,
|
|
52
|
+
kind: 'manual',
|
|
53
|
+
name,
|
|
54
|
+
transport,
|
|
55
|
+
url,
|
|
56
|
+
serverName,
|
|
57
|
+
headers,
|
|
58
|
+
auth,
|
|
59
|
+
enabled: true,
|
|
60
|
+
createdAt: Date.now(),
|
|
61
|
+
updatedAt: Date.now(),
|
|
62
|
+
};
|
|
63
|
+
}
|