@max-null/dsh-skill-mcp-center 0.4.2 → 0.5.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-skill-mcp-center
2
2
 
3
- 本插件属于 **`@max-null/*` 插件系列**——这一系列共同构成 **[SSID(思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** 桌面体验。SSID 是整合它们的盒:`dsh-capture` · `dsh-chat-rail` · `dsh-chinese-thinking` · `dsh-draft-polish` · `dsh-guardian` · `dsh-habit` · `dsh-header-unify` · `dsh-memory` · `dsh-node-appearance` · `dsh-plugin-center` · `dsh-skill-mcp-center` · `dsh-ssid-panels` · `dsh-ssid-zh-ui`。
3
+ 本插件属于 **`@max-null/*` 插件系列**——这一系列共同构成 **[SSID(思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** 桌面体验。SSID 是整合它们的盒:`dsh-capture` · `dsh-chat-rail` · `dsh-chinese-thinking` · `dsh-draft-polish` · `dsh-guardian` · `dsh-habit` · `dsh-memory` · `dsh-node-appearance` · `dsh-plugin-center` · `dsh-quick-toolbar` · `dsh-skill-mcp-center` · `dsh-ssid-panels` · `dsh-ssid-zh-ui` · `dsh-achievements`。
4
4
 
5
5
  This plugin belongs to the **`@max-null/*` family** — a set of plugins that together form the **[SSID (思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** desktop experience.
6
6
 
@@ -10,11 +10,32 @@ Skill 与 MCP 管理中心:在设置里管理 skills 与 MCP 服务器,右
10
10
 
11
11
  ## Features / 功能
12
12
 
13
- - **Skill management / Skill 管理** — browse every skill by tier (system / user / workspace / runtime), toggle model invocation via the `disable-model-invocation` frontmatter (disk-backed skills only).
13
+ - **Skill management / Skill 管理** — browse every skill by tier (system / user / workspace / runtime), toggle model invocation via the `disable-model-invocation` frontmatter (disk-backed skills only). Covers skills shipped **inside plugin packages** (e.g. `@max-null/dsh-skills`), not just the user-level roots — the host-level skill filesystem is disabled in web-app, so a plugin's own `skills/` directory would otherwise be invisible while its skills are loaded and in effect.
14
14
  - **MCP management / MCP 管理** — add / edit / remove `mcp-client` servers, enable/disable without deleting config, all **hot-applied** through `ctx.loader` (no restart).
15
- - **Live status / 实时状态** — a sidebar "MCP" tab (via `dsh-better-sidebar`) showing per-server connection state + tool count, polled while visible and following the session.
15
+ - **Live status / 实时状态** — a sidebar "MCP" tab (via `dsh-better-sidebar`) showing per-server connection state; **click a server row to expand its tools' names and descriptions** (collapsed by default — a server can expose dozens of them). Names come from `tools.schemas()`; the redundant `mcp__<server>__` prefix is hidden while the full name stays on hover. Polled while visible and following the session.
16
16
  - **Skin-compatible / 皮肤兼容** — every color uses `var(--dsw-*)` tokens.
17
17
 
18
+ ## 截图
19
+
20
+ ### 设置页:集中管理
21
+
22
+ 装完后设置里多出「Skill & MCP」一项,可逐条控制各模型对 Skill/MCP 的可见性。**入口:** 设置 → Skill & MCP
23
+
24
+ ![设置 → Skill & MCP](docs/shots/skill-mcp-1.png)
25
+
26
+ ### 侧栏:Skill 一览
27
+
28
+ 搜索 + 命名空间筛选(全部 / 全局 / 工作区),每条带开关与 `MD`(查看 SKILL.md)。**插件包内自带的 skill 同样列出**,并标出来源插件——下图里 8 个 `ssid-*` 来自 `dsh-skills`,而用户级的 `ssid-release` 没有来源标签,这正是两者的区别:
29
+
30
+ ![侧栏 Skill 面板](docs/shots/sidebar-skills.png)
31
+
32
+ ### 侧栏:MCP 状态与工具
33
+
34
+ 每个 `mcp-client` 服务器的连接状态与工具数;**点服务器行展开**工具清单(默认收起,避免几十个工具占满面板)。工具名取自工具表、描述取自 `tools.schemas()`,冗余的 `mcp__<server>__` 前缀隐去(全名留在 hover):
35
+
36
+ ![侧栏 MCP 面板](docs/shots/sidebar-mcp.png)
37
+
38
+
18
39
  ## Install / 安装
19
40
 
20
41
  ```sh
package/client.js CHANGED
@@ -57,7 +57,7 @@ var CSS = `
57
57
  .smc-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
58
58
  .smc-spacer { flex: 1; }
59
59
 
60
- .smc-badge { display: inline-flex; align-items: center; padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 500; line-height: 17px; white-space: nowrap; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-secondary); }
60
+ .smc-badge { display: inline-flex; align-items: center; flex: none; padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 500; line-height: 17px; white-space: nowrap; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-secondary); }
61
61
  .smc-badge.user { background: var(--dsw-alias-state-business-tertiary); color: var(--dsw-alias-state-business-primary); }
62
62
  .smc-badge.workspace { background: var(--dsw-alias-state-success-tertiary); color: var(--dsw-alias-state-success-primary); }
63
63
  .smc-badge.bundled { background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-tertiary); }
@@ -100,11 +100,17 @@ var CSS = `
100
100
  .smc-search-sidebar { height: 28px; padding: 0 12px; border-radius: 18px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-size: 13px; outline: none; width: 100%; font-family: inherit; box-sizing: border-box; }
101
101
  .smc-search-sidebar:focus { border-color: var(--dsw-alias-state-business-primary); }
102
102
  .smc-search-sidebar::placeholder { color: var(--dsw-alias-label-caption); }
103
- .smc-srv { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; }
103
+ .smc-srv { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; width: 100%; box-sizing: border-box; border: none; background: none; font: inherit; color: inherit; text-align: left; cursor: pointer; }
104
+ .smc-srv .smc-chevron { margin: 0 2px 0 4px; }
104
105
  .smc-srv:hover { background: var(--dsw-alias-interactive-bg-hover); }
105
106
  .smc-srv-name { font-size: 13px; font-weight: 500; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary); }
106
107
  .smc-srv-desc { font-size: 11px; color: var(--dsw-alias-label-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 0 10px 4px 26px; flex: none; }
107
108
  .smc-srv-state { font-size: 11px; color: var(--dsw-alias-label-caption); padding: 0 10px 6px 26px; }
109
+ .smc-tools { list-style: none; margin: 0 0 8px; padding: 0 10px 0 26px; display: flex; flex-direction: column; gap: 0; }
110
+ .smc-tool { display: flex; flex-direction: column; gap: 1px; padding: 5px 0; border-top: 1px solid var(--dsw-alias-border-l1); }
111
+ .smc-tool:first-child { border-top: none; }
112
+ .smc-tool-name { font-size: 11.5px; font-weight: 500; color: var(--dsw-alias-label-secondary); font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; word-break: break-all; }
113
+ .smc-tool-desc { font-size: 11px; line-height: 1.5; color: var(--dsw-alias-label-tertiary); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
108
114
  .smc-empty { padding: 24px; text-align: center; color: var(--dsw-alias-label-tertiary); font-size: 12.5px; }
109
115
  .smc-ns-bar { display: flex; gap: 4px; margin-bottom: 8px; }
110
116
  .smc-ns-btn { flex: 1; height: 26px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: none; color: var(--dsw-alias-label-secondary); font-size: 12px; cursor: pointer; font-family: inherit; }
@@ -496,7 +502,7 @@ function SkillView() {
496
502
  " \xB7 ",
497
503
  s.provider
498
504
  ] }),
499
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "smc-badge", children: s.source }),
505
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "smc-badge", children: sourceLabel(s.source) }),
500
506
  !s.modelInvocable && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "smc-badge disabled", children: t("modelDisabled") }),
501
507
  !s.writable && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "smc-badge disabled", children: t("readOnlyBadge") }),
502
508
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "smc-spacer" }),
@@ -721,6 +727,7 @@ function CenterPanel() {
721
727
  function McpSidebarTab({ visible }) {
722
728
  useLocale();
723
729
  const [items, setItems] = (0, import_react.useState)([]);
730
+ const [openIds, setOpenIds] = (0, import_react.useState)(() => /* @__PURE__ */ new Set());
724
731
  (0, import_react.useEffect)(() => {
725
732
  if (!visible) return;
726
733
  const tick = () => {
@@ -735,20 +742,50 @@ function McpSidebarTab({ visible }) {
735
742
  clearInterval(timer);
736
743
  };
737
744
  }, [visible]);
745
+ const toggle = (name) => {
746
+ setOpenIds((prev) => {
747
+ const next = new Set(prev);
748
+ if (next.has(name)) next.delete(name);
749
+ else next.add(name);
750
+ return next;
751
+ });
752
+ };
738
753
  if (items.length === 0) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "smc-empty", children: t("noMcpServer") });
739
754
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "smc-sidebar", children: items.map((s) => {
740
755
  const dotCls = s.fiberPhase === "failed" ? " failed" : s.connected ? "" : " idle";
741
756
  const state = s.fiberPhase === "failed" ? t("failed") : s.connected ? t("connected") : t("notSynced");
757
+ const expandable = s.tools.length > 0;
758
+ const open = expandable && openIds.has(s.serverName);
742
759
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
743
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "smc-srv", title: `fiber ${s.fiberPhase ?? "?"} \xB7 ${s.toolCount} tools (${s.statusSource})`, children: [
744
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `smc-dot${dotCls}` }),
745
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "smc-srv-name", children: s.serverName }),
746
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "smc-srv-count", children: [
747
- s.toolCount,
748
- " tools"
749
- ] })
750
- ] }),
751
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "smc-srv-state", children: state })
760
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
761
+ "button",
762
+ {
763
+ type: "button",
764
+ className: "smc-srv",
765
+ title: `fiber ${s.fiberPhase ?? "?"} \xB7 ${s.toolCount} tools (${s.statusSource})`,
766
+ "aria-expanded": expandable ? open : void 0,
767
+ onClick: () => {
768
+ if (expandable) toggle(s.serverName);
769
+ },
770
+ children: [
771
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `smc-dot${dotCls}` }),
772
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "smc-srv-name", children: s.serverName }),
773
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "smc-srv-count", children: [
774
+ s.toolCount,
775
+ " tools"
776
+ ] }),
777
+ expandable && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `smc-chevron${open ? " open" : ""}` })
778
+ ]
779
+ }
780
+ ),
781
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "smc-srv-state", children: state }),
782
+ open && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { className: "smc-tools", children: s.tools.map((tool) => {
783
+ const short = tool.name.startsWith(`mcp__${s.serverName}__`) ? tool.name.slice(`mcp__${s.serverName}__`.length) : tool.name;
784
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { className: "smc-tool", title: tool.name, children: [
785
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "smc-tool-name", children: short }),
786
+ tool.description !== "" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "smc-tool-desc", children: tool.description })
787
+ ] }, tool.name);
788
+ }) })
752
789
  ] }, s.serverName);
753
790
  }) });
754
791
  }
@@ -757,9 +794,17 @@ function Toast() {
757
794
  if (toast === null) return null;
758
795
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: `smc-toast ${toast.kind}`, children: toast.message });
759
796
  }
797
+ function sourceLabel(source, short = false) {
798
+ if (!source.startsWith("plugin:")) return source;
799
+ const pkg = source.slice("plugin:".length);
800
+ const name = pkg.split("/").pop() ?? pkg;
801
+ return short ? name : `plugin \xB7 ${name}`;
802
+ }
760
803
  function skillInNamespace(s, ns) {
761
804
  if (ns === "all") return true;
762
- if (ns === "global") return s.source === "user-dsh" || s.source === "user-agents";
805
+ if (ns === "global") {
806
+ return s.source === "user-dsh" || s.source === "user-agents" || s.source.startsWith("plugin:");
807
+ }
763
808
  return s.source === "project-dsh" || s.source === "project-agents";
764
809
  }
765
810
  function SidebarSkillTab({ visible, cwd }) {
@@ -840,8 +885,8 @@ function SidebarSkillTab({ visible, cwd }) {
840
885
  ${s.path}`, children: [
841
886
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `smc-dot${s.modelInvocable ? "" : " idle"}` }),
842
887
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "smc-srv-name", children: s.name }),
843
- !s.modelInvocable && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "smc-srv-count", children: t("modelDisabled") }),
844
- !s.writable && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "smc-srv-count", children: t("readOnlyBadge") }),
888
+ !s.modelInvocable && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "smc-badge", children: t("modelDisabled") }),
889
+ !s.writable && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "smc-badge", children: sourceLabel(s.source, true) }),
845
890
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
846
891
  "button",
847
892
  {
@@ -879,7 +924,18 @@ var inject = ["slots", "connection", "locale"];
879
924
  function apply(ctx) {
880
925
  injectCss();
881
926
  rpc = async (endpoint, payload = {}) => {
882
- const result = await ctx.connection.rpc.call("/skill-mcp", endpoint, payload);
927
+ const response = await fetch("/api/skill-mcp", {
928
+ method: "POST",
929
+ headers: { "content-type": "application/json" },
930
+ body: JSON.stringify({ endpoint, payload })
931
+ });
932
+ if (response.status === 404) {
933
+ const legacy = await ctx.connection.rpc.call("/skill-mcp", endpoint, payload);
934
+ if (legacy.ok) return legacy.value;
935
+ throw new Error(legacy.error?.message ?? `skill-mcp-center: ${endpoint} failed`);
936
+ }
937
+ if (!response.ok) throw new Error(`skill-mcp-center: ${endpoint} failed (HTTP ${response.status})`);
938
+ const result = await response.json();
883
939
  if (result.ok) return result.value;
884
940
  throw new Error(result.error?.message ?? `skill-mcp-center: ${endpoint} failed`);
885
941
  };
@@ -914,7 +970,14 @@ function apply(ctx) {
914
970
  sidebarCtx.effect(() => service.registerTab({
915
971
  id: "@max-null/dsh-skill-mcp-center:mcp",
916
972
  title: () => "MCP",
917
- icon: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { width: "15", height: "15", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
973
+ // 图标风格对齐 DSH 原生右栏:彩色线性、22px(「开始」页的图标容器
974
+ // .geFEbW_entryIcon 是 26px、自带占位图标 22px,整列基准即 22 ——
975
+ // 2026-09-14 用户指「尺寸 14 不太对,其他都在 22 左右」)。
976
+ // **必须是返回 ReactNode 的函数**:DSH 原生右侧栏只认函数形式,直接传 JSX 元素
977
+ // 会被忽略并回落到它的占位图标(`geFEbW_placeholder`,22px 灰圆)——
978
+ // 2026-09-14 实测:同一份 icon 在 better-sidebar 自绘的底部工作台里正常显示,
979
+ // 在原生右栏「开始」页却是占位符,据此定位到形式差异。
980
+ icon: () => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { width: "22", height: "22", viewBox: "0 0 24 24", fill: "none", stroke: "#4d6bfe", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
918
981
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("rect", { x: "2", y: "2", width: "20", height: "8", rx: "2", ry: "2" }),
919
982
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("rect", { x: "2", y: "14", width: "20", height: "8", rx: "2", ry: "2" }),
920
983
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("line", { x1: "6", y1: "6", x2: "6.01", y2: "6" }),
@@ -927,7 +990,8 @@ function apply(ctx) {
927
990
  sidebarCtx.effect(() => service.registerTab({
928
991
  id: "@max-null/dsh-skill-mcp-center:skills",
929
992
  title: () => "Skill",
930
- icon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { width: "15", height: "15", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M13 2 3 14h7l-1 8 10-12h-7l1-8z" }) }),
993
+ // 同上:必须是返回 ReactNode 的函数(DSH 原生右栏只认函数形式,传元素会回落占位图标)
994
+ icon: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { width: "22", height: "22", viewBox: "0 0 24 24", fill: "none", stroke: "#f59e0b", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M13 2 3 14h7l-1 8 10-12h-7l1-8z" }) }),
931
995
  order: 71,
932
996
  single: true,
933
997
  component: (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SidebarSkillTab, { visible: props.visible, cwd: props.scope?.cwd })
package/dist/rpc.d.ts CHANGED
@@ -1,11 +1,43 @@
1
1
  /**
2
- * `SkillMcpRpc` — a private loopback RPC channel exposing the engine to the
3
- * browser half (same seam the plugin center uses; the Typert Remote path is
4
- * closed to third parties).
2
+ * `SkillMcpRpc` — the host half of the browser's private RPC entry point.
3
+ *
4
+ * Transport (2026-09-17). Two host shapes are supported because the two
5
+ * compositions this plugin runs under differ:
6
+ *
7
+ * - **Newer hosts (0.1.5 line, e.g. the web source build)** expose
8
+ * `connection.fetch.register(...)`. This route is used there because
9
+ * `connection.rpc.handle('/skill-mcp', …)` ends at `owner.webServer.register()`
10
+ * inside Connection (`client-connection/src/rpc-host.ts:179`) and the owning
11
+ * fiber declares no `webServer` injection, so it throws
12
+ * `cannot get property "webServer" without inject`; every channel registered
13
+ * that way fails and the browser sees `HTTP 405` from the static fallback.
14
+ * `fetch.register` only writes Connection's own route map, so one exact route
15
+ * under the shared `/api` channel serves the same endpoints.
16
+ *
17
+ * - **Older hosts (e.g. the packaged SSiD kernel)** expose only the logical
18
+ * channel registry, where `rpc.handle` works, so it stays as the fallback.
19
+ *
20
+ * `connection` is resolved with `ctx.inject` rather than a declared dependency:
21
+ * it activates after this plugin, and `ctx.plugin()` does not wait for
22
+ * dependencies.
5
23
  */
6
24
  import { Service, type Context } from '@deepseek-ai/cordis';
25
+ /** Exact Fetch route serving this plugin's endpoints under the shared channel. */
26
+ export declare const ROUTE = "/api/skill-mcp";
27
+ /** Logical channel used on hosts without exact Fetch-route registration. */
28
+ export declare const LEGACY_CHANNEL = "/skill-mcp";
29
+ /** Body the browser half posts to {@link ROUTE}. */
30
+ export interface SkillMcpRequest {
31
+ /** Endpoint name, e.g. `listSkills`. */
32
+ readonly endpoint: string;
33
+ /** Endpoint arguments. */
34
+ readonly payload?: unknown;
35
+ }
7
36
  export declare class SkillMcpRpc extends Service {
8
37
  static inject: string[];
38
+ /**
39
+ * @param ctx - plugin context carrying the skill/MCP engine.
40
+ */
9
41
  constructor(ctx: Context);
10
42
  }
11
43
  export default SkillMcpRpc;
package/dist/rpc.js CHANGED
@@ -1,58 +1,123 @@
1
1
  /**
2
- * `SkillMcpRpc` — a private loopback RPC channel exposing the engine to the
3
- * browser half (same seam the plugin center uses; the Typert Remote path is
4
- * closed to third parties).
2
+ * `SkillMcpRpc` — the host half of the browser's private RPC entry point.
3
+ *
4
+ * Transport (2026-09-17). Two host shapes are supported because the two
5
+ * compositions this plugin runs under differ:
6
+ *
7
+ * - **Newer hosts (0.1.5 line, e.g. the web source build)** expose
8
+ * `connection.fetch.register(...)`. This route is used there because
9
+ * `connection.rpc.handle('/skill-mcp', …)` ends at `owner.webServer.register()`
10
+ * inside Connection (`client-connection/src/rpc-host.ts:179`) and the owning
11
+ * fiber declares no `webServer` injection, so it throws
12
+ * `cannot get property "webServer" without inject`; every channel registered
13
+ * that way fails and the browser sees `HTTP 405` from the static fallback.
14
+ * `fetch.register` only writes Connection's own route map, so one exact route
15
+ * under the shared `/api` channel serves the same endpoints.
16
+ *
17
+ * - **Older hosts (e.g. the packaged SSiD kernel)** expose only the logical
18
+ * channel registry, where `rpc.handle` works, so it stays as the fallback.
19
+ *
20
+ * `connection` is resolved with `ctx.inject` rather than a declared dependency:
21
+ * it activates after this plugin, and `ctx.plugin()` does not wait for
22
+ * dependencies.
5
23
  */
6
24
  import { Service } from '@deepseek-ai/cordis';
7
- const CHANNEL = '/skill-mcp';
25
+ /** Exact Fetch route serving this plugin's endpoints under the shared channel. */
26
+ export const ROUTE = '/api/skill-mcp';
27
+ /** Logical channel used on hosts without exact Fetch-route registration. */
28
+ export const LEGACY_CHANNEL = '/skill-mcp';
8
29
  function internal(message) {
9
30
  return { ok: false, error: { code: 'internal', message, details: {} } };
10
31
  }
32
+ /**
33
+ * Wrap one endpoint result as a JSON response.
34
+ * @param value - RPC result envelope.
35
+ * @returns response carrying the envelope.
36
+ */
37
+ function jsonResponse(value) {
38
+ return new Response(JSON.stringify(value), { headers: { 'content-type': 'application/json' } });
39
+ }
11
40
  export class SkillMcpRpc extends Service {
12
- static inject = ['skillMcp', 'connection'];
41
+ static inject = ['skillMcp'];
42
+ /**
43
+ * @param ctx - plugin context carrying the skill/MCP engine.
44
+ */
13
45
  constructor(ctx) {
14
46
  super(ctx, 'skillMcpRpc');
15
- ctx.connection.rpc.handle(CHANNEL, async (endpoint, payload) => {
16
- try {
17
- const p = (payload ?? {});
18
- switch (endpoint) {
19
- case 'listSkills': {
20
- const cwd = p.cwd;
21
- return { ok: true, value: await ctx.skillMcp.listSkills(typeof cwd === 'string' ? cwd : undefined) };
22
- }
23
- case 'toggleSkill': {
24
- const path = p.path;
25
- if (typeof path !== 'string' || path === '')
26
- return internal('toggleSkill: path is required');
27
- return { ok: true, value: await ctx.skillMcp.toggleSkill(path) };
28
- }
29
- case 'readSkill': {
30
- const path = p.path;
31
- if (typeof path !== 'string' || path === '')
32
- return internal('readSkill: path is required');
33
- const cwd = p.cwd;
34
- return { ok: true, value: await ctx.skillMcp.readSkill(path, typeof cwd === 'string' ? cwd : undefined) };
35
- }
36
- case 'listMcpServers':
37
- return { ok: true, value: await ctx.skillMcp.listMcpServers() };
38
- case 'createMcpServer':
39
- return { ok: true, value: await ctx.skillMcp.createMcpServer(p.config) };
40
- case 'updateMcpServer':
41
- return { ok: true, value: await ctx.skillMcp.updateMcpServer(String(p.id), p.config) };
42
- case 'removeMcpServer':
43
- return { ok: true, value: await ctx.skillMcp.removeMcpServer(String(p.id)) };
44
- case 'setMcpServerEnabled':
45
- return { ok: true, value: await ctx.skillMcp.setMcpServerEnabled(String(p.id), p.enabled === true) };
46
- case 'mcpStatus':
47
- return { ok: true, value: await ctx.skillMcp.mcpStatus() };
48
- default:
49
- return internal(`unknown endpoint "${endpoint}"`);
50
- }
47
+ ctx.inject(['connection'], (connectionCtx) => {
48
+ // Both reads are attempted: the published peer types predate the
49
+ // `connection` declaration on Context, so neither is typed here.
50
+ const surface = connectionCtx;
51
+ const connection = surface.connection ?? surface.get?.('connection');
52
+ if (connection === undefined)
53
+ return;
54
+ if (typeof connection.fetch?.register === 'function') {
55
+ connection.fetch.register({
56
+ path: ROUTE,
57
+ methods: ['POST'],
58
+ requestBody: 'buffered',
59
+ fetch: async (request) => {
60
+ let body;
61
+ try {
62
+ body = await request.json();
63
+ }
64
+ catch {
65
+ return jsonResponse(internal('request body must be JSON'));
66
+ }
67
+ return jsonResponse(await dispatch(ctx, body));
68
+ },
69
+ });
70
+ return;
71
+ }
72
+ connection.rpc.handle(LEGACY_CHANNEL, (endpoint, payload) => dispatch(ctx, { endpoint, payload }));
73
+ });
74
+ }
75
+ }
76
+ /**
77
+ * Run one endpoint against the engine.
78
+ * @param ctx - plugin context carrying the skill/MCP engine.
79
+ * @param body - decoded request body.
80
+ * @returns the endpoint result, or an `internal` failure.
81
+ */
82
+ async function dispatch(ctx, body) {
83
+ try {
84
+ const p = (body.payload ?? {});
85
+ switch (body.endpoint) {
86
+ case 'listSkills': {
87
+ const cwd = p.cwd;
88
+ return { ok: true, value: await ctx.skillMcp.listSkills(typeof cwd === 'string' ? cwd : undefined) };
51
89
  }
52
- catch (error) {
53
- return internal(error instanceof Error ? error.message : String(error));
90
+ case 'toggleSkill': {
91
+ const path = p.path;
92
+ if (typeof path !== 'string' || path === '')
93
+ return internal('toggleSkill: path is required');
94
+ return { ok: true, value: await ctx.skillMcp.toggleSkill(path) };
54
95
  }
55
- }, { authority: 'loopback' });
96
+ case 'readSkill': {
97
+ const path = p.path;
98
+ if (typeof path !== 'string' || path === '')
99
+ return internal('readSkill: path is required');
100
+ const cwd = p.cwd;
101
+ return { ok: true, value: await ctx.skillMcp.readSkill(path, typeof cwd === 'string' ? cwd : undefined) };
102
+ }
103
+ case 'listMcpServers':
104
+ return { ok: true, value: await ctx.skillMcp.listMcpServers() };
105
+ case 'createMcpServer':
106
+ return { ok: true, value: await ctx.skillMcp.createMcpServer(p.config) };
107
+ case 'updateMcpServer':
108
+ return { ok: true, value: await ctx.skillMcp.updateMcpServer(String(p.id), p.config) };
109
+ case 'removeMcpServer':
110
+ return { ok: true, value: await ctx.skillMcp.removeMcpServer(String(p.id)) };
111
+ case 'setMcpServerEnabled':
112
+ return { ok: true, value: await ctx.skillMcp.setMcpServerEnabled(String(p.id), p.enabled === true) };
113
+ case 'mcpStatus':
114
+ return { ok: true, value: await ctx.skillMcp.mcpStatus() };
115
+ default:
116
+ return internal(`unknown endpoint "${body.endpoint}"`);
117
+ }
118
+ }
119
+ catch (error) {
120
+ return internal(error instanceof Error ? error.message : String(error));
56
121
  }
57
122
  }
58
123
  export default SkillMcpRpc;
package/dist/service.d.ts CHANGED
@@ -44,11 +44,23 @@ export interface McpServer {
44
44
  disabled: boolean;
45
45
  fiberPhase: string | null;
46
46
  }
47
+ /** One tool a server contributes, as the management surface lists it. */
48
+ export interface McpToolView {
49
+ name: string;
50
+ description: string;
51
+ }
47
52
  /** Runtime status of one MCP server (sidebar polling). */
48
53
  export interface McpServerStatus {
49
54
  serverName: string;
50
55
  fiberPhase: string | null;
51
56
  toolCount: number;
57
+ /**
58
+ * The tools themselves. A bare count says nothing about what the server
59
+ * actually offers, so the surface lists names and descriptions too
60
+ * (2026-09-14 用户:MCP 的 tools 信息太少了,最好有名称和介绍)。
61
+ * Names come from `tools.schemas()`, which whitelists name/description.
62
+ */
63
+ tools: McpToolView[];
52
64
  connected: boolean;
53
65
  statusSource: 'seam' | 'derived';
54
66
  }
@@ -66,7 +78,25 @@ export declare class SkillMcpService extends Service {
66
78
  static inject: string[];
67
79
  private readonly officialSkillDirs;
68
80
  constructor(ctx: Context, config?: SkillConfig);
69
- /** User-level skills plus, when a workspace is given, its project-level skills. */
81
+ /**
82
+ * Skill roots that live **inside loaded plugin packages**, i.e.
83
+ * `<node_modules>/<pkg>/skills`.
84
+ *
85
+ * The host-level skill filesystem is disabled in web-app (presets own
86
+ * discovery), so a plugin that ships skills — `@max-null/dsh-skills` and
87
+ * `@max-null/dsh-plugin-center` both do — keeps them on disk inside its own
88
+ * package, where no user-level root can see them. Without this the
89
+ * management surface showed 17 user skills while 8 plugin skills were loaded
90
+ * and in effect (2026-09-14 用户报「skill 生效但不展示」).
91
+ *
92
+ * Only **loaded** entries are probed, so this costs one existence check per
93
+ * plugin rather than a scan of the whole node_modules tree.
94
+ */
95
+ private pluginSkillDirs;
96
+ /**
97
+ * User-level skills, project-level skills for the given workspace, skills
98
+ * bundled inside loaded plugin packages, and any configured official roots.
99
+ */
70
100
  listSkills(cwd?: string): Promise<SkillView[]>;
71
101
  /** Flip one disk-backed skill's model invocation by rewriting its SKILL.md frontmatter. */
72
102
  toggleSkill(path: string): Promise<SkillView>;
package/dist/service.js CHANGED
@@ -7,9 +7,11 @@
7
7
  * `ctx.mcpStatus` seam with a derived fallback.
8
8
  */
9
9
  import { Service } from '@deepseek-ai/cordis';
10
+ import { existsSync } from 'node:fs';
10
11
  import { readdir, readFile, writeFile } from 'node:fs/promises';
11
12
  import { homedir } from 'node:os';
12
13
  import { join, sep } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
13
15
  import { parseSkillFrontmatter, setDisableModelInvocation } from "./frontmatter.js";
14
16
  /** Runtime mirror of cordis FiberState (a cross-package const enum). */
15
17
  const FIBER_PHASE = {
@@ -48,6 +50,21 @@ const SKILL_ROOTS = [
48
50
  { path: join(homedir(), '.dsh', 'skills'), source: 'user-dsh' },
49
51
  { path: join(homedir(), '.agents', 'skills'), source: 'user-agents' },
50
52
  ];
53
+ /**
54
+ * This package's own `node_modules` directory — the tree every loaded plugin
55
+ * was resolved from.
56
+ *
57
+ * Derived from the module URL instead of assuming
58
+ * `$DSH_HOME/profiles/<p>/node_modules`: the profile layout and even the
59
+ * directory name vary, while this module is always inside that tree.
60
+ * @returns the node_modules path, or null when the module is not under one.
61
+ */
62
+ function ownNodeModules() {
63
+ const here = fileURLToPath(import.meta.url);
64
+ const marker = `${sep}node_modules${sep}`;
65
+ const at = here.lastIndexOf(marker);
66
+ return at < 0 ? null : here.slice(0, at + marker.length - 1);
67
+ }
51
68
  /** Absolute SKILL.md path for one directory/file entry, or null. */
52
69
  function skillPathFor(root, name, isDirectory) {
53
70
  if (isDirectory)
@@ -100,7 +117,39 @@ export class SkillMcpService extends Service {
100
117
  super(ctx, 'skillMcp');
101
118
  this.officialSkillDirs = config.officialSkillDirs ?? [];
102
119
  }
103
- /** User-level skills plus, when a workspace is given, its project-level skills. */
120
+ /**
121
+ * Skill roots that live **inside loaded plugin packages**, i.e.
122
+ * `<node_modules>/<pkg>/skills`.
123
+ *
124
+ * The host-level skill filesystem is disabled in web-app (presets own
125
+ * discovery), so a plugin that ships skills — `@max-null/dsh-skills` and
126
+ * `@max-null/dsh-plugin-center` both do — keeps them on disk inside its own
127
+ * package, where no user-level root can see them. Without this the
128
+ * management surface showed 17 user skills while 8 plugin skills were loaded
129
+ * and in effect (2026-09-14 用户报「skill 生效但不展示」).
130
+ *
131
+ * Only **loaded** entries are probed, so this costs one existence check per
132
+ * plugin rather than a scan of the whole node_modules tree.
133
+ */
134
+ pluginSkillDirs() {
135
+ const nm = ownNodeModules();
136
+ if (nm === null)
137
+ return [];
138
+ const out = [];
139
+ for (const entry of this.ctx.loader.entries()) {
140
+ const name = entry.options.name;
141
+ if (typeof name !== 'string' || name === '' || name.startsWith('.') || name.startsWith('/'))
142
+ continue;
143
+ const dir = join(nm, ...name.split('/'), 'skills');
144
+ if (existsSync(dir))
145
+ out.push({ dir, label: `plugin:${name}` });
146
+ }
147
+ return out;
148
+ }
149
+ /**
150
+ * User-level skills, project-level skills for the given workspace, skills
151
+ * bundled inside loaded plugin packages, and any configured official roots.
152
+ */
104
153
  async listSkills(cwd) {
105
154
  const skills = [];
106
155
  for (const root of SKILL_ROOTS) {
@@ -110,6 +159,9 @@ export class SkillMcpService extends Service {
110
159
  skills.push(...await scanSkillRoot(join(cwd, '.agents', 'skills'), 'project-agents'));
111
160
  skills.push(...await scanSkillRoot(join(cwd, '.dsh', 'skills'), 'project-dsh'));
112
161
  }
162
+ for (const plugin of this.pluginSkillDirs()) {
163
+ skills.push(...await scanSkillRoot(plugin.dir, plugin.label, false, 'plugin'));
164
+ }
113
165
  for (const dir of this.officialSkillDirs) {
114
166
  skills.push(...await scanSkillRoot(dir, 'bundled', false, 'dsh-official'));
115
167
  }
@@ -150,6 +202,8 @@ export class SkillMcpService extends Service {
150
202
  if (cwd !== undefined && cwd !== '') {
151
203
  roots.push(join(cwd, '.agents', 'skills'), join(cwd, '.dsh', 'skills'));
152
204
  }
205
+ for (const plugin of this.pluginSkillDirs())
206
+ roots.push(plugin.dir);
153
207
  roots.push(...this.officialSkillDirs);
154
208
  // Segment-boundary prefix check: a sibling directory like `skills-notes`
155
209
  // must not satisfy a `skills` root. join(root, '') normalizes to root.
@@ -211,29 +265,40 @@ export class SkillMcpService extends Service {
211
265
  /** Runtime status per server: upstream `mcpStatus` seam when present, else derived. */
212
266
  async mcpStatus() {
213
267
  const servers = await this.listMcpServers();
268
+ // Tool identity always comes from the mounted tool table — `schemas()`
269
+ // whitelists name/description, which is exactly what the surface needs.
270
+ // The seam, when present, only refines the count and connection facts.
271
+ const schemas = this.ctx.tools.schemas();
272
+ const toolsOf = (serverName) => {
273
+ const prefix = `mcp__${serverName}__`;
274
+ return schemas
275
+ .filter(s => s.name.startsWith(prefix))
276
+ .map(s => ({ name: s.name, description: s.description ?? '' }));
277
+ };
214
278
  const seam = this.ctx.get('mcpStatus');
215
279
  if (seam !== undefined) {
216
280
  const byName = new Map(seam.list().map(s => [s.serverName, s]));
217
281
  return servers.map(s => {
218
282
  const st = byName.get(s.serverName);
283
+ const tools = toolsOf(s.serverName);
219
284
  return {
220
285
  serverName: s.serverName,
221
286
  fiberPhase: s.fiberPhase,
222
- toolCount: st?.toolCount ?? 0,
287
+ toolCount: st?.toolCount ?? tools.length,
288
+ tools,
223
289
  connected: st?.phase === 'connected',
224
290
  statusSource: 'seam',
225
291
  };
226
292
  });
227
293
  }
228
- const toolNames = this.ctx.tools.schemas().map(s => s.name);
229
294
  return servers.map(s => {
230
- const prefix = `mcp__${s.serverName}__`;
231
- const toolCount = toolNames.filter(n => n.startsWith(prefix)).length;
295
+ const tools = toolsOf(s.serverName);
232
296
  return {
233
297
  serverName: s.serverName,
234
298
  fiberPhase: s.fiberPhase,
235
- toolCount,
236
- connected: !s.disabled && s.fiberPhase === 'active' && toolCount > 0,
299
+ toolCount: tools.length,
300
+ tools,
301
+ connected: !s.disabled && s.fiberPhase === 'active' && tools.length > 0,
237
302
  statusSource: 'derived',
238
303
  };
239
304
  });
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max-null/dsh-skill-mcp-center",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
4
4
  "description": "Skill & MCP management center for DeepSeek Harness — manage skills and MCP servers in Settings, live MCP status in the sidebar",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -21,7 +21,9 @@
21
21
  "files": [
22
22
  "dist",
23
23
  "client.js",
24
- "cordis.patch.yml"
24
+ "cordis.patch.yml",
25
+ "docs/shots",
26
+ "release-notes.md"
25
27
  ],
26
28
  "dsh": {
27
29
  "bundle": {
@@ -0,0 +1,35 @@
1
+ # Release Notes — @max-null/dsh-skill-mcp-center
2
+
3
+ ## 0.5.1 (2026-09-17)
4
+
5
+ ### 修复
6
+
7
+ - 修正 web 环境下设置面板的「加载失败:transport failure for /skill-mcp/listSkills: HTTP 405」。
8
+ 根因在宿主侧:`ctx.connection.rpc.handle()` 注册逻辑通道时会访问 `client-connection`
9
+ 未声明的 `webServer` 注入,cordis 属性代理抛错后注册静默失败(DSH 源码形态特有;
10
+ 打包内核形态不受影响)。上游报告:deepseek-ai/deepseek-harness discussions #6880。
11
+
12
+ ### 变更
13
+
14
+ - 传输层改为双路径自适应:
15
+ - 宿主提供 `connection.fetch.register` → 注册 `POST /api/skill-mcp` 精确路由
16
+ (只写 Connection 自己的路由表,不触碰 `owner.webServer`);
17
+ - 宿主只提供逻辑通道注册表(老打包内核)→ 自动回退 `/skill-mcp`;
18
+ - 浏览器侧先试新路由,**收到 404 才回退**(新路由未注册时 `/api/*` 是 404,根路径才是 405)。
19
+ - `connection` 服务改用 `ctx.inject(['connection'], cb)` 获取:它在插件 apply 时尚未就绪,
20
+ 且 `ctx.plugin(P)` 不等待依赖。
21
+
22
+ ### 兼容性
23
+
24
+ - 无 API 变更:端点集合、请求体、响应信封(RpcResult)与 peer 依赖均未变。
25
+ - 面板端与宿主端需同版本升级(浏览器侧的传输策略随之切换)。
26
+
27
+ ### 测试
28
+
29
+ - 新增 `tests/rpc-transport.test.ts`(8 条):传输层选择、服务晚到时不抢先注册、
30
+ 路由派发、JSON 解析失败与引擎异常收敛为 `internal` 信封。
31
+ - 全量 18 条通过;`npm run typecheck` 无错。
32
+
33
+ ### 验证
34
+
35
+ - web(源码形态)实测:`POST /api/skill-mcp` → 200,面板恢复,控制台 0 errors。