@chatcode/cco-better-sidebar 0.18.0-alpha.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/LICENSE +21 -0
- package/README.md +554 -0
- package/README_EN.md +563 -0
- package/assets/logo.svg +1 -0
- package/cordis.patch.yml +79 -0
- package/lib/client-editor.js +55744 -0
- package/lib/client-mermaid.js +208256 -0
- package/lib/client-registry.js +30378 -0
- package/lib/client-terminal.js +16894 -0
- package/lib/client.js +30378 -0
- package/lib/index.js +6897 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-opens.d.ts +65 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/chatcode-update.d.ts +47 -0
- package/lib/types/client/AccountMenu.d.ts +1 -0
- package/lib/types/client/AiRatioSection.d.ts +3 -0
- package/lib/types/client/BrowserView.d.ts +39 -0
- package/lib/types/client/ChatcodeGate.d.ts +6 -0
- package/lib/types/client/CodingPlanModelsSettings.d.ts +9 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +13 -0
- package/lib/types/client/FenceErrorNotice.d.ts +5 -0
- package/lib/types/client/FileTree.d.ts +41 -0
- package/lib/types/client/FreeWindow.d.ts +29 -0
- package/lib/types/client/GitView.d.ts +12 -0
- package/lib/types/client/MarkdownHtml.d.ts +32 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/ReleaseNotesDialog.d.ts +11 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/Sidebar.d.ts +8 -0
- package/lib/types/client/TabBar.d.ts +54 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +27 -0
- package/lib/types/client/UpdateDialog.d.ts +10 -0
- package/lib/types/client/UpdateSettingsPanel.d.ts +1 -0
- package/lib/types/client/UpdateSettingsSection.d.ts +10 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/UsageHeatmapSection.d.ts +4 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +345 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +17 -0
- package/lib/types/client/browser.d.ts +60 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chat-presentation.d.ts +13 -0
- package/lib/types/client/chunk-loader.d.ts +124 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +66 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/intercept.d.ts +44 -0
- package/lib/types/client/lang.d.ts +14 -0
- package/lib/types/client/layout-push.d.ts +15 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales-ar.d.ts +18 -0
- package/lib/types/client/locales-de.d.ts +4 -0
- package/lib/types/client/locales-fr.d.ts +10 -0
- package/lib/types/client/locales-hi.d.ts +17 -0
- package/lib/types/client/locales-id.d.ts +15 -0
- package/lib/types/client/locales-it.d.ts +8 -0
- package/lib/types/client/locales-ja.d.ts +17 -0
- package/lib/types/client/locales-ko.d.ts +9 -0
- package/lib/types/client/locales-nl.d.ts +16 -0
- package/lib/types/client/locales-pl.d.ts +19 -0
- package/lib/types/client/locales-pt.d.ts +1 -0
- package/lib/types/client/locales-ru.d.ts +13 -0
- package/lib/types/client/locales-sv.d.ts +1 -0
- package/lib/types/client/locales-th.d.ts +17 -0
- package/lib/types/client/locales-tr.d.ts +17 -0
- package/lib/types/client/locales-vi.d.ts +17 -0
- package/lib/types/client/locales-zh-HK.d.ts +32 -0
- package/lib/types/client/locales-zh-MO.d.ts +32 -0
- package/lib/types/client/locales-zh-TW.d.ts +32 -0
- package/lib/types/client/locales.d.ts +571 -0
- package/lib/types/client/markdown-html.d.ts +96 -0
- package/lib/types/client/markdown-images.d.ts +45 -0
- package/lib/types/client/markdown-labels.d.ts +21 -0
- package/lib/types/client/md-toc.d.ts +4 -0
- package/lib/types/client/mermaid-blocks.d.ts +47 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +52 -0
- package/lib/types/client/paths.d.ts +43 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/pinned.d.ts +86 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +29 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +445 -0
- package/lib/types/client/session-pins.d.ts +14 -0
- package/lib/types/client/settings-nav-icon.d.ts +27 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/split-pane.d.ts +38 -0
- package/lib/types/client/state.d.ts +480 -0
- package/lib/types/client/tab-content-memo.d.ts +40 -0
- package/lib/types/client/terminal-font.d.ts +78 -0
- package/lib/types/client/terminal-links.d.ts +119 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/ui-tweaks/appicons.d.ts +16 -0
- package/lib/types/client/ui-tweaks/archive.d.ts +42 -0
- package/lib/types/client/ui-tweaks/branding.d.ts +45 -0
- package/lib/types/client/ui-tweaks/cachehit.d.ts +41 -0
- package/lib/types/client/ui-tweaks/gitbar.d.ts +103 -0
- package/lib/types/client/ui-tweaks/index.d.ts +279 -0
- package/lib/types/client/ui-tweaks/mcp.d.ts +28 -0
- package/lib/types/client/ui-tweaks/notifier.d.ts +125 -0
- package/lib/types/client/ui-tweaks/whale.d.ts +55 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/useChatcodeAccount.d.ts +10 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/codingplan.d.ts +37 -0
- package/lib/types/config.d.ts +95 -0
- package/lib/types/context-types.d.ts +540 -0
- package/lib/types/fs-operations.d.ts +30 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +124 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/login-route.d.ts +2 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/path-security.d.ts +26 -0
- package/lib/types/prefs-shared.d.ts +257 -0
- package/lib/types/pty-deps.d.ts +79 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/tools.d.ts +34 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/ui-tweaks/archive.d.ts +71 -0
- package/lib/types/ui-tweaks/config.d.ts +186 -0
- package/lib/types/ui-tweaks/git-web.d.ts +43 -0
- package/lib/types/ui-tweaks/git.d.ts +311 -0
- package/lib/types/ui-tweaks/index.d.ts +17 -0
- package/lib/types/ui-tweaks/mcp.d.ts +103 -0
- package/lib/types/ui-tweaks/turn-outcome.d.ts +57 -0
- package/lib/types/ui-tweaks/web.d.ts +55 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +213 -0
- package/scripts/install.ps1 +310 -0
- package/scripts/install.sh +287 -0
- package/src/agent-opens.ts +265 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/chatcode-update.ts +193 -0
- package/src/client/AccountMenu.tsx +177 -0
- package/src/client/AiRatioSection.module.css +329 -0
- package/src/client/AiRatioSection.tsx +292 -0
- package/src/client/BrowserView.tsx +289 -0
- package/src/client/ChatcodeGate.tsx +35 -0
- package/src/client/CodingPlanModelsSettings.module.css +118 -0
- package/src/client/CodingPlanModelsSettings.tsx +101 -0
- package/src/client/DiffTab.tsx +114 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +563 -0
- package/src/client/FenceErrorNotice.tsx +40 -0
- package/src/client/FileTree.tsx +701 -0
- package/src/client/FreeWindow.tsx +337 -0
- package/src/client/GitView.tsx +722 -0
- package/src/client/MarkdownHtml.tsx +296 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/ReleaseNotesDialog.tsx +91 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +932 -0
- package/src/client/SideCardSection.tsx +1169 -0
- package/src/client/Sidebar.tsx +1857 -0
- package/src/client/TabBar.tsx +365 -0
- package/src/client/TerminalView.tsx +424 -0
- package/src/client/TextEditor.tsx +468 -0
- package/src/client/TreePanel.tsx +279 -0
- package/src/client/UpdateDialog.tsx +186 -0
- package/src/client/UpdateSettingsPanel.tsx +29 -0
- package/src/client/UpdateSettingsSection.tsx +22 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/UsageHeatmapSection.module.css +440 -0
- package/src/client/UsageHeatmapSection.tsx +301 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +433 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +61 -0
- package/src/client/browser.ts +167 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +275 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chat-presentation.ts +95 -0
- package/src/client/chunk-loader.ts +350 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +170 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +501 -0
- package/src/client/intercept.tsx +172 -0
- package/src/client/lang.ts +225 -0
- package/src/client/layout-push.ts +37 -0
- package/src/client/layout.css +156 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales-ar.ts +387 -0
- package/src/client/locales-de.ts +372 -0
- package/src/client/locales-fr.ts +379 -0
- package/src/client/locales-hi.ts +386 -0
- package/src/client/locales-id.ts +384 -0
- package/src/client/locales-it.ts +377 -0
- package/src/client/locales-ja.ts +486 -0
- package/src/client/locales-ko.ts +378 -0
- package/src/client/locales-nl.ts +384 -0
- package/src/client/locales-pl.ts +388 -0
- package/src/client/locales-pt.ts +369 -0
- package/src/client/locales-ru.ts +384 -0
- package/src/client/locales-sv.ts +369 -0
- package/src/client/locales-th.ts +386 -0
- package/src/client/locales-tr.ts +386 -0
- package/src/client/locales-vi.ts +386 -0
- package/src/client/locales-zh-HK.ts +401 -0
- package/src/client/locales-zh-MO.ts +401 -0
- package/src/client/locales-zh-TW.ts +401 -0
- package/src/client/locales.ts +1229 -0
- package/src/client/markdown-html.ts +331 -0
- package/src/client/markdown-images.ts +137 -0
- package/src/client/markdown-labels.tsx +31 -0
- package/src/client/md-toc.tsx +127 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +406 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +79 -0
- package/src/client/paths.ts +63 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/pinned.ts +184 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +153 -0
- package/src/client/plugins-viewers.ts +44 -0
- package/src/client/prefs.ts +216 -0
- package/src/client/produced-files.ts +120 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +873 -0
- package/src/client/session-pins.tsx +334 -0
- package/src/client/settings-nav-icon.ts +55 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +3607 -0
- package/src/client/split-pane.tsx +333 -0
- package/src/client/state.ts +1646 -0
- package/src/client/tab-content-memo.ts +55 -0
- package/src/client/terminal-font.ts +224 -0
- package/src/client/terminal-links.ts +194 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/ui-tweaks/appicons.ts +24 -0
- package/src/client/ui-tweaks/archive.tsx +281 -0
- package/src/client/ui-tweaks/branding.tsx +232 -0
- package/src/client/ui-tweaks/cachehit.tsx +122 -0
- package/src/client/ui-tweaks/gitbar.tsx +2559 -0
- package/src/client/ui-tweaks/index.tsx +1696 -0
- package/src/client/ui-tweaks/mcp.tsx +582 -0
- package/src/client/ui-tweaks/notifier.ts +514 -0
- package/src/client/ui-tweaks/whale.tsx +171 -0
- package/src/client/upload.ts +187 -0
- package/src/client/useChatcodeAccount.ts +103 -0
- package/src/client/wco.ts +120 -0
- package/src/codingplan.ts +146 -0
- package/src/config.ts +207 -0
- package/src/context-types.ts +559 -0
- package/src/fs-operations.ts +97 -0
- package/src/fs-search.ts +113 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +446 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1524 -0
- package/src/invariant.ts +32 -0
- package/src/login-route.ts +26 -0
- package/src/open-external.ts +90 -0
- package/src/path-security.ts +81 -0
- package/src/prefs-shared.ts +303 -0
- package/src/pty-deps.ts +241 -0
- package/src/pty-manager.ts +376 -0
- package/src/tools.ts +482 -0
- package/src/trust-fence.ts +84 -0
- package/src/ui-tweaks/archive.ts +293 -0
- package/src/ui-tweaks/config.ts +257 -0
- package/src/ui-tweaks/git-web.ts +421 -0
- package/src/ui-tweaks/git.ts +1524 -0
- package/src/ui-tweaks/index.ts +61 -0
- package/src/ui-tweaks/mcp.ts +535 -0
- package/src/ui-tweaks/turn-outcome.ts +105 -0
- package/src/ui-tweaks/web.ts +225 -0
- package/src/wire.ts +110 -0
- package/vendor/addon-fit.js +2 -0
- package/vendor/xterm.css +218 -0
- package/vendor/xterm.js +5 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-ui-tweaks — server half.
|
|
3
|
+
*
|
|
4
|
+
* Registers the `ui-tweaks` settings namespace so users can tune the
|
|
5
|
+
* conversation UI either from the Settings panel or by editing the settings
|
|
6
|
+
* document directly (settings.yaml `ui-tweaks:` section). All rendering work
|
|
7
|
+
* happens in the browser bundle (`src/client`), which reads and writes this
|
|
8
|
+
* namespace through the same-origin route mounted here — the Web settings RPC
|
|
9
|
+
* only exposes a fixed allowlist of namespaces since rc.6, so a custom route is
|
|
10
|
+
* the supported way for a plugin to own a configuration page.
|
|
11
|
+
* @module dsh-ui-tweaks
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
15
|
+
import {
|
|
16
|
+
UI_TWEAKS_SETTINGS_NAMESPACE,
|
|
17
|
+
Config,
|
|
18
|
+
type UITweaksConfig,
|
|
19
|
+
} from './config.ts'
|
|
20
|
+
import { installTurnOutcomeProjection } from './turn-outcome.ts'
|
|
21
|
+
import { UITweaksWebBackend, installUITweaksWeb } from './web.ts'
|
|
22
|
+
import { GitBackend } from './git.ts'
|
|
23
|
+
import { installGitWeb } from './git-web.ts'
|
|
24
|
+
import { ArchiveBackend, installArchiveWeb } from './archive.ts'
|
|
25
|
+
import { McpBackend, installMcpWeb } from './mcp.ts'
|
|
26
|
+
|
|
27
|
+
export const name = 'dsh-ui-tweaks'
|
|
28
|
+
|
|
29
|
+
/** Required services: the settings seam is the whole server-side surface. */
|
|
30
|
+
export const inject = ['settings']
|
|
31
|
+
|
|
32
|
+
export function apply(ctx: Context): void {
|
|
33
|
+
ctx.settings.register(UI_TWEAKS_SETTINGS_NAMESPACE, Config, {
|
|
34
|
+
applies: 'live',
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// The task notifier classifies turn endings (completed / aborted / failed)
|
|
38
|
+
// through this session projection (same registration pattern as above).
|
|
39
|
+
installTurnOutcomeProjection(ctx)
|
|
40
|
+
|
|
41
|
+
// The browser Settings panel talks to the namespace through this same-origin
|
|
42
|
+
// route (the Web settings RPC only exposes a fixed allowlist since rc.6).
|
|
43
|
+
installUITweaksWeb(ctx, new UITweaksWebBackend(ctx))
|
|
44
|
+
|
|
45
|
+
// The GitBar runs git in the session's working directory through these
|
|
46
|
+
// same-origin routes (sessions/llm are optional services, duck-typed).
|
|
47
|
+
const readConfig = (): UITweaksConfig => ctx.settings.get(UI_TWEAKS_SETTINGS_NAMESPACE) as UITweaksConfig
|
|
48
|
+
installGitWeb(ctx, new GitBackend(ctx, readConfig))
|
|
49
|
+
|
|
50
|
+
// The Archive panel lists archived sessions and restores ("deletes") them
|
|
51
|
+
// through these same-origin routes (workspace registry + storage domain
|
|
52
|
+
// are optional services, resolved at request time).
|
|
53
|
+
installArchiveWeb(ctx, new ArchiveBackend(ctx))
|
|
54
|
+
|
|
55
|
+
// The MCP manager lists the configured MCP servers (loader entries + tool
|
|
56
|
+
// registry) and restarts them through these same-origin routes (loader is
|
|
57
|
+
// an optional service, resolved at request time).
|
|
58
|
+
installMcpWeb(ctx, new McpBackend(ctx))
|
|
59
|
+
|
|
60
|
+
ctx.logger.info('[ChatCode CLI UI Tweaks] settings namespace registered and Web routes mounted')
|
|
61
|
+
}
|
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-ui-tweaks — MCP manager (host half).
|
|
3
|
+
*
|
|
4
|
+
* The browser MCP manager (the "MCP 管理" settings section, shown when the
|
|
5
|
+
* mcpManagerEnabled setting is on) manages the configured MCP servers through
|
|
6
|
+
* this same-origin route:
|
|
7
|
+
*
|
|
8
|
+
* - **list** — every `@deepseek-ai/dsh-mcp-client` loader entry with its live
|
|
9
|
+
* fiber status (active / failed / loading / stopped / disabled), its config
|
|
10
|
+
* (serverName, transport, command / url, env), a YAML rendering of the
|
|
11
|
+
* config (for the YAML editor), and the tools it registered
|
|
12
|
+
* (`mcp__<serverName>__*`) counted from the tool registry.
|
|
13
|
+
* - **save / remove / set-enabled** — ADD, EDIT, DELETE, and ENABLE/DISABLE a
|
|
14
|
+
* server durably by editing the profile's own `cordis.patch.yml` (the same
|
|
15
|
+
* file the user edits by hand): the entry is inserted / replaced / removed /
|
|
16
|
+
* flagged `disabled`, the file is written atomically, and DSH's built-in
|
|
17
|
+
* patch watcher (`watchUserPatches`, Cordis HMR) hot-reloads the loader, so
|
|
18
|
+
* the MCP server starts / stops / restarts live. The loader tree itself is
|
|
19
|
+
* never rewritten, so the patch file stays the single source of truth.
|
|
20
|
+
*
|
|
21
|
+
* Env values are returned to the same-origin browser (the user's own machine
|
|
22
|
+
* and config) because the editor must be able to show and modify them.
|
|
23
|
+
* @module dsh-ui-tweaks/mcp
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readFile, rename, writeFile } from 'node:fs/promises'
|
|
27
|
+
import { dirname, join } from 'node:path'
|
|
28
|
+
import { fileURLToPath } from 'node:url'
|
|
29
|
+
import { parse, parseDocument, stringify, Document, YAMLMap, YAMLSeq } from 'yaml'
|
|
30
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
31
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
32
|
+
// Type-only import activates the webServer Context declaration.
|
|
33
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
34
|
+
import { isRecord, json, messageOf, readJson, requestError, sameOriginPost } from './web.ts'
|
|
35
|
+
|
|
36
|
+
/** Exact route used by the browser MCP manager. */
|
|
37
|
+
export const MCP_ROUTE = '/_dsh/ui-tweaks/mcp'
|
|
38
|
+
|
|
39
|
+
/** Module specifier of the MCP client plugin; its loader entries are the MCP servers. */
|
|
40
|
+
const MCP_MODULE = '@deepseek-ai/dsh-mcp-client'
|
|
41
|
+
|
|
42
|
+
/** Lifecycle status of one MCP server instance. */
|
|
43
|
+
export type McpStatus = 'disabled' | 'stopped' | 'active' | 'failed' | 'loading'
|
|
44
|
+
|
|
45
|
+
/** Public view of one configured MCP server. */
|
|
46
|
+
export interface McpServerView {
|
|
47
|
+
/** Loader entry id (e.g. `mcp-mysql-local`). */
|
|
48
|
+
id: string
|
|
49
|
+
/** The server's tool namespace (`mcp__<serverName>__*`). */
|
|
50
|
+
serverName: string
|
|
51
|
+
transport: string
|
|
52
|
+
command?: string
|
|
53
|
+
args: string[]
|
|
54
|
+
url?: string
|
|
55
|
+
headers: Record<string, string>
|
|
56
|
+
env: Record<string, string>
|
|
57
|
+
/** Per-tool-call timeout in milliseconds. */
|
|
58
|
+
toolCallTimeoutMs?: number
|
|
59
|
+
/** The server's config rendered as YAML (for the YAML editor). */
|
|
60
|
+
yaml: string
|
|
61
|
+
disabled: boolean
|
|
62
|
+
status: McpStatus
|
|
63
|
+
toolCount: number
|
|
64
|
+
/** Raw tool names registered by this server, sorted. */
|
|
65
|
+
tools: string[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Editor payload for add/edit/set-enabled. */
|
|
69
|
+
export interface McpServerInput {
|
|
70
|
+
id: string
|
|
71
|
+
serverName: string
|
|
72
|
+
transport: 'stdio' | 'streamable-http'
|
|
73
|
+
command?: string
|
|
74
|
+
args?: string[]
|
|
75
|
+
url?: string
|
|
76
|
+
headers?: Record<string, string>
|
|
77
|
+
env?: Record<string, string>
|
|
78
|
+
toolCallTimeoutMs?: number
|
|
79
|
+
disabled: boolean
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface McpSnapshot {
|
|
83
|
+
servers: McpServerView[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type McpRequest =
|
|
87
|
+
| { action: 'remove'; id: string }
|
|
88
|
+
| { action: 'set-enabled'; id: string; enabled: boolean }
|
|
89
|
+
| { action: 'save'; server: McpServerInput }
|
|
90
|
+
|
|
91
|
+
/** Structural face of one Cordis loader entry (see cordis-plugin-loader). */
|
|
92
|
+
interface LoaderEntryLike {
|
|
93
|
+
id: string
|
|
94
|
+
options: { id?: string; name?: string; config?: Record<string, unknown> }
|
|
95
|
+
fiber?: { state?: number } | null
|
|
96
|
+
disabled: boolean
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Structural face of the Cordis Loader service. */
|
|
100
|
+
interface LoaderLike {
|
|
101
|
+
config?: { baseUrl?: string }
|
|
102
|
+
entries(): Iterable<LoaderEntryLike>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function statusOf(disabled: boolean, fiber: LoaderEntryLike['fiber']): McpStatus {
|
|
106
|
+
if (disabled) return 'disabled'
|
|
107
|
+
if (fiber === undefined || fiber === null) return 'stopped'
|
|
108
|
+
// Cordis FiberState: 0 pending, 1 loading, 2 active, 3 failed, 4 disposed, 5 unloading.
|
|
109
|
+
if (fiber.state === 2) return 'active'
|
|
110
|
+
if (fiber.state === 3) return 'failed'
|
|
111
|
+
return 'loading'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function asString(value: unknown): string | undefined {
|
|
115
|
+
return typeof value === 'string' ? value : undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function asStringRecord(value: unknown): Record<string, string> {
|
|
119
|
+
if (!isRecord(value)) return {}
|
|
120
|
+
const out: Record<string, string> = {}
|
|
121
|
+
for (const [key, val] of Object.entries(value)) if (typeof val === 'string') out[key] = val
|
|
122
|
+
return out
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function asStringArray(value: unknown): string[] {
|
|
126
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Same-origin MCP read/write handler. */
|
|
130
|
+
export class McpBackend {
|
|
131
|
+
constructor(private readonly ctx: Context) {}
|
|
132
|
+
|
|
133
|
+
private loader(): LoaderLike | undefined {
|
|
134
|
+
return this.ctx.get('loader') as unknown as LoaderLike | undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Best-effort enumeration of the global tool registry (no public list API). */
|
|
138
|
+
private toolNames(): Set<string> {
|
|
139
|
+
const tools = this.ctx.get('tools') as unknown as { layers?: { global?: { tools?: Map<string, unknown> } } } | undefined
|
|
140
|
+
try {
|
|
141
|
+
return new Set(tools?.layers?.global?.tools?.keys() ?? [])
|
|
142
|
+
} catch {
|
|
143
|
+
return new Set()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private snapshot(): McpSnapshot {
|
|
148
|
+
const loader = this.loader()
|
|
149
|
+
const tools = this.toolNames()
|
|
150
|
+
const servers: McpServerView[] = []
|
|
151
|
+
if (loader === undefined) return { servers }
|
|
152
|
+
for (const entry of loader.entries()) {
|
|
153
|
+
if (entry.options.name !== MCP_MODULE) continue
|
|
154
|
+
// The loader entry id carries the tree path (e.g. `include:mcp-mysql-local`);
|
|
155
|
+
// the raw `options.id` is the patch-file identity (`mcp-mysql-local`).
|
|
156
|
+
const id = entry.options.id ?? entry.id
|
|
157
|
+
const config = entry.options.config ?? {}
|
|
158
|
+
const serverName = asString(config.serverName) ?? id
|
|
159
|
+
const transport = asString(config.transport) ?? 'stdio'
|
|
160
|
+
const prefix = `mcp__${serverName}__`
|
|
161
|
+
const serverTools: string[] = []
|
|
162
|
+
for (const name of tools) if (name.startsWith(prefix)) serverTools.push(name.slice(prefix.length))
|
|
163
|
+
serverTools.sort()
|
|
164
|
+
const command = asString(config.command)
|
|
165
|
+
const url = asString(config.url)
|
|
166
|
+
const toolCallTimeoutMs = typeof config.toolCallTimeoutMs === 'number' && Number.isFinite(config.toolCallTimeoutMs)
|
|
167
|
+
? config.toolCallTimeoutMs
|
|
168
|
+
: undefined
|
|
169
|
+
let yamlText = ''
|
|
170
|
+
try {
|
|
171
|
+
yamlText = stringify(config)
|
|
172
|
+
} catch {
|
|
173
|
+
yamlText = ''
|
|
174
|
+
}
|
|
175
|
+
servers.push({
|
|
176
|
+
id,
|
|
177
|
+
serverName,
|
|
178
|
+
transport,
|
|
179
|
+
...(command !== undefined ? { command } : {}),
|
|
180
|
+
args: asStringArray(config.args),
|
|
181
|
+
...(url !== undefined ? { url } : {}),
|
|
182
|
+
headers: asStringRecord(config.headers),
|
|
183
|
+
env: asStringRecord(config.env),
|
|
184
|
+
...(toolCallTimeoutMs !== undefined ? { toolCallTimeoutMs } : {}),
|
|
185
|
+
yaml: yamlText,
|
|
186
|
+
disabled: entry.disabled,
|
|
187
|
+
status: statusOf(entry.disabled, entry.fiber),
|
|
188
|
+
toolCount: serverTools.length,
|
|
189
|
+
tools: serverTools,
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
return { servers }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// -------------------------------------------------------------------------
|
|
196
|
+
// Durable patch-file persistence (the profile's cordis.patch.yml)
|
|
197
|
+
// -------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/** Resolve the profile directory: the root Include entry's config.path is
|
|
200
|
+
* the profile's `cordis.yml` (a `file://` URL), whose parent IS the profile
|
|
201
|
+
* directory. Falls back to the loader's baseUrl. */
|
|
202
|
+
private profileDir(): string | undefined {
|
|
203
|
+
const loader = this.loader()
|
|
204
|
+
if (loader === undefined) return undefined
|
|
205
|
+
for (const entry of loader.entries()) {
|
|
206
|
+
if (entry.options.name === 'cordis:include') {
|
|
207
|
+
const rawPath = entry.options.config?.path
|
|
208
|
+
if (typeof rawPath === 'string' && rawPath.length > 0) {
|
|
209
|
+
try {
|
|
210
|
+
return dirname(fileURLToPath(rawPath))
|
|
211
|
+
} catch {
|
|
212
|
+
return dirname(rawPath)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const baseUrl = loader.config?.baseUrl
|
|
218
|
+
if (typeof baseUrl === 'string' && baseUrl.length > 0) return baseUrl
|
|
219
|
+
return undefined
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private patchFilePath(): string {
|
|
223
|
+
const dir = this.profileDir()
|
|
224
|
+
if (dir === undefined) throw new Error('cannot locate the profile directory')
|
|
225
|
+
return join(dir, 'cordis.patch.yml')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Find one MCP entry in the patch document; also reports its insert seq, index, and owning patch. */
|
|
229
|
+
private findPatchEntry(doc: Document, id: string): { entry: YAMLMap; seq: YAMLSeq; index: number; patch: YAMLMap } | undefined {
|
|
230
|
+
const contents = doc.contents
|
|
231
|
+
if (!(contents instanceof YAMLSeq)) return undefined
|
|
232
|
+
for (const patch of contents.items) {
|
|
233
|
+
if (!(patch instanceof YAMLMap)) continue
|
|
234
|
+
const insert = patch.get('insert', true)
|
|
235
|
+
if (!(insert instanceof YAMLSeq)) continue
|
|
236
|
+
for (let index = 0; index < insert.items.length; index += 1) {
|
|
237
|
+
const entry = insert.items[index]
|
|
238
|
+
if (!(entry instanceof YAMLMap)) continue
|
|
239
|
+
if (entry.get('id', false) === id) return { entry, seq: insert, index, patch }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return undefined
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The insert seq new MCP entries should join (first one already holding MCP entries, else the first insert). */
|
|
246
|
+
private insertTarget(doc: Document): YAMLSeq {
|
|
247
|
+
const contents = doc.contents
|
|
248
|
+
if (!(contents instanceof YAMLSeq)) throw new Error('profile patch file is not a YAML array')
|
|
249
|
+
let firstInsert: YAMLSeq | undefined
|
|
250
|
+
for (const patch of contents.items) {
|
|
251
|
+
if (!(patch instanceof YAMLMap)) continue
|
|
252
|
+
const insert = patch.get('insert', true)
|
|
253
|
+
if (!(insert instanceof YAMLSeq)) continue
|
|
254
|
+
firstInsert ??= insert
|
|
255
|
+
for (const entry of insert.items) {
|
|
256
|
+
if (entry instanceof YAMLMap && entry.get('name', false) === MCP_MODULE) return insert
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (firstInsert !== undefined) return firstInsert
|
|
260
|
+
// No insert section at all: append a new patch `- insert:` with one entry.
|
|
261
|
+
const fresh = doc.createNode({ insert: [] }) as YAMLMap
|
|
262
|
+
contents.add(fresh)
|
|
263
|
+
const insert = fresh.get('insert', true)
|
|
264
|
+
if (!(insert instanceof YAMLSeq)) throw new Error('failed to create an insert section in the profile patch file')
|
|
265
|
+
return insert
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private entryNode(doc: Document, input: McpServerInput): unknown {
|
|
269
|
+
const entry: Record<string, unknown> = {
|
|
270
|
+
id: input.id,
|
|
271
|
+
name: MCP_MODULE,
|
|
272
|
+
config: buildConfig(input),
|
|
273
|
+
}
|
|
274
|
+
if (input.disabled) entry.disabled = true
|
|
275
|
+
return doc.createNode(entry)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private async mutatePatchFile(mutate: (doc: Document) => void): Promise<void> {
|
|
279
|
+
const path = this.patchFilePath()
|
|
280
|
+
let doc: Document
|
|
281
|
+
try {
|
|
282
|
+
doc = parseDocument(await readFile(path, 'utf8'))
|
|
283
|
+
} catch (error) {
|
|
284
|
+
throw new Error(`failed to read profile patch file: ${messageOf(error)}`)
|
|
285
|
+
}
|
|
286
|
+
if (!(doc.contents instanceof YAMLSeq) || doc.contents.items.length === 0) {
|
|
287
|
+
doc.contents = doc.createNode([])
|
|
288
|
+
}
|
|
289
|
+
mutate(doc)
|
|
290
|
+
const tmp = `${path}.${process.pid}.tmp`
|
|
291
|
+
try {
|
|
292
|
+
await writeFile(tmp, doc.toString(), 'utf8')
|
|
293
|
+
await rename(tmp, path)
|
|
294
|
+
} catch (error) {
|
|
295
|
+
throw new Error(`failed to write profile patch file: ${messageOf(error)}`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private validateInput(input: McpServerInput): void {
|
|
300
|
+
if (!isRecord(input)) throw new TypeError('server must be an object')
|
|
301
|
+
if (typeof input.id !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/u.test(input.id)) {
|
|
302
|
+
throw new TypeError('实例 ID 只能包含字母、数字、- 和 _(1–64 字符)')
|
|
303
|
+
}
|
|
304
|
+
if (typeof input.serverName !== 'string' || !/^[A-Za-z0-9_-]{1,32}$/u.test(input.serverName)) {
|
|
305
|
+
throw new TypeError('服务器名称只能包含字母、数字、- 和 _(1–32 字符)')
|
|
306
|
+
}
|
|
307
|
+
if (input.transport !== 'stdio' && input.transport !== 'streamable-http') {
|
|
308
|
+
throw new TypeError('transport 必须是 stdio 或 streamable-http')
|
|
309
|
+
}
|
|
310
|
+
if (input.toolCallTimeoutMs !== undefined && (!Number.isInteger(input.toolCallTimeoutMs) || input.toolCallTimeoutMs <= 0)) {
|
|
311
|
+
throw new TypeError('超时时间必须是正整数(毫秒)')
|
|
312
|
+
}
|
|
313
|
+
if (input.transport === 'streamable-http') {
|
|
314
|
+
if (typeof input.url !== 'string' || !/^https?:\/\//u.test(input.url)) {
|
|
315
|
+
throw new TypeError('URL 必须以 http:// 或 https:// 开头')
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
if (typeof input.command !== 'string' || input.command.trim().length === 0) {
|
|
319
|
+
throw new TypeError('启动命令不能为空')
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Add or edit one server (upsert by id), persisting to the profile patch file. */
|
|
325
|
+
private async save(input: McpServerInput): Promise<void> {
|
|
326
|
+
this.validateInput(input)
|
|
327
|
+
const serverNameTaken = (this.snapshot().servers.some(server =>
|
|
328
|
+
server.serverName === input.serverName && server.id !== input.id))
|
|
329
|
+
if (serverNameTaken) throw new Error(`服务器名称 ${input.serverName} 已被其他实例使用`)
|
|
330
|
+
await this.mutatePatchFile((doc) => {
|
|
331
|
+
const found = this.findPatchEntry(doc, input.id)
|
|
332
|
+
if (found !== undefined) {
|
|
333
|
+
// Replace the entry in place (edit), preserving its position.
|
|
334
|
+
found.seq.items[found.index] = this.entryNode(doc, input) as YAMLMap
|
|
335
|
+
} else {
|
|
336
|
+
this.insertTarget(doc).add(this.entryNode(doc, input))
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Delete one server from the profile patch file. */
|
|
342
|
+
private async remove(id: string): Promise<void> {
|
|
343
|
+
await this.mutatePatchFile((doc) => {
|
|
344
|
+
const found = this.findPatchEntry(doc, id)
|
|
345
|
+
if (found === undefined) throw new Error(`MCP server instance not found in patch file: ${id}`)
|
|
346
|
+
found.seq.items.splice(found.index, 1)
|
|
347
|
+
// Clean up an insert section / patch that became empty.
|
|
348
|
+
const contents = doc.contents
|
|
349
|
+
if (contents instanceof YAMLSeq) {
|
|
350
|
+
const patchIndex = contents.items.findIndex(item => item === found.patch)
|
|
351
|
+
if (found.seq.items.length === 0 && patchIndex >= 0) contents.items.splice(patchIndex, 1)
|
|
352
|
+
// Keep the file a valid top-level array.
|
|
353
|
+
if (contents.items.length === 0) doc.contents = doc.createNode([])
|
|
354
|
+
}
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Enable or disable one server in the profile patch file. */
|
|
359
|
+
private async setEnabled(id: string, enabled: boolean): Promise<void> {
|
|
360
|
+
await this.mutatePatchFile((doc) => {
|
|
361
|
+
const found = this.findPatchEntry(doc, id)
|
|
362
|
+
if (found === undefined) throw new Error(`MCP server instance not found in patch file: ${id}`)
|
|
363
|
+
if (enabled) found.entry.delete('disabled')
|
|
364
|
+
else found.entry.set('disabled', true)
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Handle the exact MCP route. */
|
|
369
|
+
async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
370
|
+
if (req.method === 'GET') {
|
|
371
|
+
try {
|
|
372
|
+
json(res, 200, { ok: true, value: this.snapshot() })
|
|
373
|
+
} catch (error) {
|
|
374
|
+
this.ctx.logger.warn('dsh-ui-tweaks MCP snapshot failed: %s', messageOf(error))
|
|
375
|
+
requestError(res, 503, 'mcp-unavailable', 'MCP manager is unavailable')
|
|
376
|
+
}
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
if (req.method !== 'POST') {
|
|
380
|
+
res.setHeader('Allow', 'GET, POST')
|
|
381
|
+
requestError(res, 405, 'method-not-allowed', 'Use GET or POST')
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
if (!sameOriginPost(req)) {
|
|
385
|
+
requestError(res, 403, 'origin-rejected', 'The request must originate from this ChatCode CLI Web application')
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
let request: McpRequest
|
|
389
|
+
try {
|
|
390
|
+
request = parseRequest(await readJson(req))
|
|
391
|
+
} catch (error) {
|
|
392
|
+
requestError(res, 400, 'invalid-request', messageOf(error))
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
if (request.action === 'remove') await this.remove(request.id)
|
|
397
|
+
else if (request.action === 'set-enabled') await this.setEnabled(request.id, request.enabled)
|
|
398
|
+
else await this.save(request.server)
|
|
399
|
+
json(res, 200, { ok: true, value: this.snapshot() })
|
|
400
|
+
} catch (error) {
|
|
401
|
+
this.ctx.logger.warn('dsh-ui-tweaks MCP mutation failed: %s', messageOf(error))
|
|
402
|
+
requestError(res, 400, 'mcp-rejected', messageOf(error))
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function buildConfig(input: McpServerInput): Record<string, unknown> {
|
|
408
|
+
const base: Record<string, unknown> = {
|
|
409
|
+
transport: input.transport,
|
|
410
|
+
serverName: input.serverName,
|
|
411
|
+
}
|
|
412
|
+
if (input.toolCallTimeoutMs !== undefined) base.toolCallTimeoutMs = input.toolCallTimeoutMs
|
|
413
|
+
if (input.transport === 'streamable-http') {
|
|
414
|
+
return {
|
|
415
|
+
...base,
|
|
416
|
+
url: input.url ?? '',
|
|
417
|
+
headers: input.headers ?? {},
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
...base,
|
|
422
|
+
command: input.command ?? '',
|
|
423
|
+
args: input.args ?? [],
|
|
424
|
+
env: input.env ?? {},
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Config keys the MCP client plugin accepts; anything else is a typo and rejected. */
|
|
429
|
+
const ALLOWED_CONFIG_KEYS = new Set([
|
|
430
|
+
'serverName', 'transport', 'command', 'args', 'env', 'cwd',
|
|
431
|
+
'toolCallTimeoutMs', 'failOnStartupError', 'reconnect', 'url', 'headers',
|
|
432
|
+
])
|
|
433
|
+
|
|
434
|
+
/** Parse and validate one server config given as raw YAML text. */
|
|
435
|
+
function parseYamlConfig(source: string): Omit<McpServerInput, 'id' | 'disabled'> {
|
|
436
|
+
let raw: unknown
|
|
437
|
+
try {
|
|
438
|
+
raw = parse(source)
|
|
439
|
+
} catch (error) {
|
|
440
|
+
throw new TypeError(`YAML 解析失败:${messageOf(error)}`)
|
|
441
|
+
}
|
|
442
|
+
if (!isRecord(raw)) throw new TypeError('YAML 必须是对象(一段 MCP 配置)')
|
|
443
|
+
const unknownKeys = Object.keys(raw).filter(key => !ALLOWED_CONFIG_KEYS.has(key))
|
|
444
|
+
if (unknownKeys.length > 0) {
|
|
445
|
+
throw new TypeError(`YAML 包含未知字段:${unknownKeys.join(', ')}(允许:serverName, transport, command, args, env, cwd, toolCallTimeoutMs, failOnStartupError, reconnect, url, headers)`)
|
|
446
|
+
}
|
|
447
|
+
const transport = raw.transport === 'streamable-http' ? 'streamable-http' : raw.transport === 'stdio' ? 'stdio' : undefined
|
|
448
|
+
if (transport === undefined) throw new TypeError('transport 必须是 stdio 或 streamable-http')
|
|
449
|
+
if (typeof raw.serverName !== 'string' || !/^[A-Za-z0-9_-]{1,32}$/u.test(raw.serverName)) {
|
|
450
|
+
throw new TypeError('serverName 只能包含字母、数字、- 和 _(1–32 字符)')
|
|
451
|
+
}
|
|
452
|
+
let toolCallTimeoutMs: number | undefined
|
|
453
|
+
if (raw.toolCallTimeoutMs !== undefined) {
|
|
454
|
+
if (typeof raw.toolCallTimeoutMs !== 'number' || !Number.isInteger(raw.toolCallTimeoutMs) || raw.toolCallTimeoutMs <= 0) {
|
|
455
|
+
throw new TypeError('toolCallTimeoutMs 必须是正整数(毫秒)')
|
|
456
|
+
}
|
|
457
|
+
toolCallTimeoutMs = raw.toolCallTimeoutMs
|
|
458
|
+
}
|
|
459
|
+
if (transport === 'streamable-http') {
|
|
460
|
+
if (typeof raw.url !== 'string' || !/^https?:\/\//u.test(raw.url)) throw new TypeError('url 必须以 http:// 或 https:// 开头')
|
|
461
|
+
return {
|
|
462
|
+
serverName: raw.serverName,
|
|
463
|
+
transport,
|
|
464
|
+
url: raw.url,
|
|
465
|
+
headers: asStringRecord(raw.headers),
|
|
466
|
+
...(toolCallTimeoutMs !== undefined ? { toolCallTimeoutMs } : {}),
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (typeof raw.command !== 'string' || raw.command.trim().length === 0) throw new TypeError('command 不能为空')
|
|
470
|
+
return {
|
|
471
|
+
serverName: raw.serverName,
|
|
472
|
+
transport,
|
|
473
|
+
command: raw.command,
|
|
474
|
+
args: asStringArray(raw.args),
|
|
475
|
+
env: asStringRecord(raw.env),
|
|
476
|
+
...(toolCallTimeoutMs !== undefined ? { toolCallTimeoutMs } : {}),
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function parseRequest(value: unknown): McpRequest {
|
|
481
|
+
if (!isRecord(value)) throw new TypeError('request body must be an object')
|
|
482
|
+
if (value.action === 'remove') {
|
|
483
|
+
const id = value.id
|
|
484
|
+
if (typeof id !== 'string' || id.length === 0) throw new TypeError('id must be a non-empty string')
|
|
485
|
+
return { action: 'remove', id }
|
|
486
|
+
}
|
|
487
|
+
if (value.action === 'set-enabled') {
|
|
488
|
+
const id = value.id
|
|
489
|
+
if (typeof id !== 'string' || id.length === 0) throw new TypeError('id must be a non-empty string')
|
|
490
|
+
if (typeof value.enabled !== 'boolean') throw new TypeError('enabled must be a boolean')
|
|
491
|
+
return { action: 'set-enabled', id, enabled: value.enabled }
|
|
492
|
+
}
|
|
493
|
+
if (value.action === 'save') {
|
|
494
|
+
const server = isRecord(value.server) ? value.server : undefined
|
|
495
|
+
const disabled = server?.disabled === true
|
|
496
|
+
const idRaw = typeof server?.id === 'string' ? server.id : undefined
|
|
497
|
+
if (typeof value.yaml === 'string') {
|
|
498
|
+
// YAML-mode save: the config is written as raw YAML text.
|
|
499
|
+
if (typeof idRaw !== 'string' || idRaw.length === 0) throw new TypeError('save (yaml) requires server.id')
|
|
500
|
+
return { action: 'save', server: { ...parseYamlConfig(value.yaml), id: idRaw, disabled } }
|
|
501
|
+
}
|
|
502
|
+
if (server === undefined) throw new TypeError('server must be an object')
|
|
503
|
+
const input: McpServerInput = {
|
|
504
|
+
id: server.id as string,
|
|
505
|
+
serverName: server.serverName as string,
|
|
506
|
+
transport: server.transport === 'streamable-http' ? 'streamable-http' : 'stdio',
|
|
507
|
+
command: typeof server.command === 'string' ? server.command : '',
|
|
508
|
+
args: Array.isArray(server.args) ? server.args.filter((arg): arg is string => typeof arg === 'string') : [],
|
|
509
|
+
url: typeof server.url === 'string' ? server.url : '',
|
|
510
|
+
headers: isRecord(server.headers) ? asStringRecord(server.headers) : {},
|
|
511
|
+
env: isRecord(server.env) ? asStringRecord(server.env) : {},
|
|
512
|
+
...(typeof server.toolCallTimeoutMs === 'number' ? { toolCallTimeoutMs: server.toolCallTimeoutMs } : {}),
|
|
513
|
+
disabled,
|
|
514
|
+
}
|
|
515
|
+
return { action: 'save', server: input }
|
|
516
|
+
}
|
|
517
|
+
throw new TypeError('action must be "remove", "set-enabled" or "save"')
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Attach the MCP route whenever a webServer service is present.
|
|
522
|
+
* @param ctx - plugin context owning route effects.
|
|
523
|
+
* @param backend - MCP handler.
|
|
524
|
+
*/
|
|
525
|
+
export function installMcpWeb(ctx: Context, backend: McpBackend): void {
|
|
526
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
527
|
+
webCtx.effect(() => {
|
|
528
|
+
return webCtx.webServer.register({
|
|
529
|
+
kind: 'exact',
|
|
530
|
+
path: MCP_ROUTE,
|
|
531
|
+
handler: (req, res) => backend.handle(req, res),
|
|
532
|
+
})
|
|
533
|
+
}, 'dsh-ui-tweaks: MCP routes')
|
|
534
|
+
})
|
|
535
|
+
}
|