fix(repository-plugin): reject incomplete MCP publication
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md
|
||||
2026-07-30-static-repository-plugin-format.md: afc88bb974ccc6ef7843eaf1e98f16b65bb6d4b3
|
||||
2026-07-30-static-repository-plugin-format.zh.md: a6dc6ec6ec89c04b79edea279029ae33091ca0af
|
||||
2026-07-30-static-repository-plugin-format.md: c66ee111eb0cac9e0d6c54581855ffc18efc8611
|
||||
2026-07-30-static-repository-plugin-format.zh.md: 969d7eb536158137807826eba2313670a7d37580
|
||||
+2
-2
@@ -20,7 +20,7 @@ Loading the DSH package registers that builtin as an effect. A generated wrapper
|
||||
|
||||
Each prepared skill set mounts `dsh-skill-local` with a unique `repository:<package-name>` provider name, only the copied custom roots, and watching disabled. `dsh-skill-local` therefore gains two general configuration fields: `providerName` and `includeDefaultRoots`. Their defaults preserve its existing single local provider; repository instances set a distinct name and exclude project/user roots so multiple instances neither collide nor duplicate host-local discovery.
|
||||
|
||||
Each `.mcp.json` server becomes one existing `dsh-mcp-client` child. The adapter accepts the common root `{ "mcpServers": ... }`; stdio definitions allow only optional `type: "stdio"`, `command`, `args`, and `env`, while HTTP definitions allow only `type: "http"`, `url`, and `headers`. Exact `${NAME}` process-environment references expand at runtime, after cache preparation; missing names fail Plugin load. HTTP maps to the client's Streamable HTTP transport, and stdio uses the prepared package directory as `cwd`. The existing client alone owns connection attempts, failure logging, remote tool synchronization, tool calls, and disconnects. Consequently an MCP connection failure keeps its established successful-plugin/no-tools behavior and is not reclassified as a repository preparation or Loader failure.
|
||||
Each `.mcp.json` server becomes one existing `dsh-mcp-client` child. The adapter accepts the common root `{ "mcpServers": ... }`; stdio definitions allow only optional `type: "stdio"`, `command`, `args`, and `env`, while HTTP definitions allow only `type: "http"`, `url`, and `headers`. Exact `${NAME}` process-environment references expand at runtime, after cache preparation; missing names fail Plugin load. HTTP maps to the client's Streamable HTTP transport, and stdio uses the prepared package directory as `cwd`. The existing client alone owns connection attempts, failure logging, remote tool synchronization, tool calls, and disconnects. Repository instances enable strict startup, so an initial connection, discovery, or tool-registration failure rejects the repository Loader generation; non-strict standalone clients retain the logged successful-plugin/no-tools behavior.
|
||||
|
||||
Unknown MCP fields reject. This intentionally excludes OAuth, `auth` objects, `CLAUDE_PLUGIN_ROOT`, and a broader Claude compatibility contract. Commands, hooks, agents, rules, and other foreign manifest conventions are not inferred from static repository layout; DSH-native behavior uses the explicit trusted Cordis entry. Repository subdirectory selection and GitHub source configuration belong to the [standalone app integration](../feature/2026-07-30-config-only-repository-plugins.md), not this static adapter.
|
||||
|
||||
@@ -34,7 +34,7 @@ Unknown MCP fields reject. This intentionally excludes OAuth, `auth` objects, `C
|
||||
|
||||
**Watch prepared repository assets.** Rejected because an exact repository cache generation is immutable. Ref, subdirectory, or configuration changes select a new generation; a second watcher would create an unowned refresh identity.
|
||||
|
||||
**Treat MCP connect failures as Loader update failures.** Rejected because the existing MCP client deliberately contains connect failures and exposes no tools. Changing that semantic only for repository sources would create two failure contracts for the same server configuration.
|
||||
**Make every MCP connect failure a Loader update failure.** Rejected because optional standalone MCP clients deliberately contain startup failures and expose no tools. The MCP client instead owns an explicit strict-startup option, which repository adapters enable for their declared servers.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@
|
||||
|
||||
每份已准备 skill 集合都会挂载 `dsh-skill-local`,使用唯一的 `repository:<package-name>` 提供方名称、仅包含复制后的自定义根,并禁用监视。因此 `dsh-skill-local` 新增两个通用配置字段:`providerName` 和 `includeDefaultRoots`。默认值保持原有单一本地提供方行为;repository 实例设置不同名称并排除项目/用户根,使多个实例既不冲突,也不会重复宿主本地发现。
|
||||
|
||||
`.mcp.json` 中的每个 server 都变成一个现有 `dsh-mcp-client` 子级。适配层接受通用根对象 `{ "mcpServers": ... }`;stdio 定义只允许可选的 `type: "stdio"`、`command`、`args` 与 `env`,HTTP 定义只允许 `type: "http"`、`url` 与 `headers`。严格的 `${NAME}` 进程环境变量引用在运行时、cache 准备之后展开;缺失变量会使 Plugin 加载失败。HTTP 映射到 client 的 Streamable HTTP transport,stdio 使用已准备 package 目录作为 `cwd`。只有现有 client 负责连接尝试、失败日志、远端工具同步、工具调用和断开。因此 MCP 连接失败会继续沿用“Plugin 成功但不注册工具”的既有行为,不会被重新分类为 repository 准备或 Loader 失败。
|
||||
`.mcp.json` 中的每个 server 都变成一个现有 `dsh-mcp-client` 子级。适配层接受通用根对象 `{ "mcpServers": ... }`;stdio 定义只允许可选的 `type: "stdio"`、`command`、`args` 与 `env`,HTTP 定义只允许 `type: "http"`、`url` 与 `headers`。严格的 `${NAME}` 进程环境变量引用在运行时、cache 准备之后展开;缺失变量会使 Plugin 加载失败。HTTP 映射到 client 的 Streamable HTTP transport,stdio 使用已准备 package 目录作为 `cwd`。只有现有 client 负责连接尝试、失败日志、远端工具同步、工具调用和断开。Repository 实例会启用严格启动,因此初始连接、发现或工具注册失败会拒绝 repository Loader generation;非严格的独立 client 则保留“记录日志、Plugin 成功但不注册工具”的行为。
|
||||
|
||||
未知 MCP 字段会被拒绝。这里有意排除 OAuth、`auth` 对象、`CLAUDE_PLUGIN_ROOT` 和更广泛的 Claude 兼容契约。命令、hook、agent(智能体)、规则和其他外来 manifest 约定不会从静态 repository 布局中推断出来;DSH 原生行为使用显式的受信任 Cordis 入口。Repository 子目录选择与 GitHub 源配置属于[独立应用集成](../feature/2026-07-30-config-only-repository-plugins.md),而不是本静态适配器。
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
**监视已准备 repository 资源。** 拒绝,因为一个精确 repository cache generation 是不可变的。Ref、子目录或配置变化会选择新 generation;第二套 watcher 会创造一套没有所有者的刷新身份。
|
||||
|
||||
**把 MCP 连接失败当作 Loader 更新失败。** 拒绝,因为现有 MCP client 有意收束连接失败并不暴露工具。只对 repository source 改变该语义,会让同一 server 配置拥有两套失败契约。
|
||||
**把每次 MCP 连接失败都当作 Loader 更新失败。** 拒绝,因为可选的独立 MCP client 会有意收束启动失败,并且不暴露工具。MCP client 改为自行提供显式的严格启动选项,由 repository 适配器为其声明的 server 启用。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md
|
||||
2026-08-08-trusted-repository-package-code.md: 6c568964c2d4392054107acb1e73ace8f50d5f0b
|
||||
2026-08-08-trusted-repository-package-code.zh.md: c2587286379398ab853b71c8b8819d274d06c65b
|
||||
2026-08-08-trusted-repository-package-code.md: 387479b3b36a8bc5e145641ae40802b3090ced70
|
||||
2026-08-08-trusted-repository-package-code.zh.md: aff34d4485553f076c5dd1b71f0a3c5b5c62bc8f
|
||||
+4
-4
@@ -16,9 +16,9 @@ A configured repository package is trusted code. Its `.dsh-plugin/package.json`
|
||||
|
||||
The package owns its npm dependencies and build toolchain. It declares the published `@deepseek-ai/dsh-repository-plugin` package to obtain the `dsh-plugin-prepare` executable. `scripts.prepack` is a non-empty package-authored command that must invoke that dependency-provided helper, but it may first run `tsc`, `tsdown`, or any other build. DSH neither injects the helper, parses the shell program, nor compiles repository source. The helper validates the metadata after the preceding build, requires the configured entry to resolve to a file within `.dsh-plugin`, validates and copies declared static assets, and writes the prepared `dsh-plugin.mjs` wrapper. The installed package must retain a `prepack` declaration containing that helper command; a missing dependency, wrapper, or build output fails before a cache generation becomes usable.
|
||||
|
||||
The generated wrapper first mounts the DSH-owned static runtime for skills and MCP definitions, then dynamically imports and unwraps the explicit entry and mounts it as a child. Both children must reach Cordis `ACTIVE`; an unsatisfied `inject` or startup exception rejects the repository Loader transaction instead of committing an inert generation. Loader removal, failed replacement, and parent disposal unwind the entry, skill providers, MCP clients, and their effects together.
|
||||
The generated wrapper first mounts the DSH-owned static runtime for skills and MCP definitions, then dynamically imports and unwraps the explicit entry and mounts it as a child. The wrapper statically declares dependencies implied by the prepared manifest; an entry module's additional `inject` is discovered only when mounted and must already be available in the host composition. Both children must reach Cordis `ACTIVE`; an unsatisfied `inject` or startup exception rejects the repository Loader transaction instead of committing an inert generation. Loader removal, failed replacement, and parent disposal unwind the entry, skill providers, MCP clients, and their effects together.
|
||||
|
||||
`dsh-mcp-client` resolves its initial connection and tool synchronization promise as part of Plugin application. Its entry is an `async function`, not an ordinary function returning a Promise: Cordis identifies prototype-bearing ordinary functions as constructors and does not treat a constructor's returned Promise as startup work. A valid server's tools therefore exist before its parent repository wrapper activates and before a one-shot application starts its first model request. Its `failOnStartupError` config preserves optional standalone servers by default while letting repository adapters require their declared servers. Repository-translated MCP clients enable that mode, so initial connection or discovery failure rejects the candidate generation and rollback still closes the transport.
|
||||
`dsh-mcp-client` resolves its initial connection and tool synchronization promise as part of Plugin application. Its entry is an `async function`, not an ordinary function returning a Promise: Cordis identifies prototype-bearing ordinary functions as constructors and does not treat a constructor's returned Promise as startup work. A valid server's tools therefore exist before its parent repository wrapper activates and before a one-shot application starts its first model request. Its `failOnStartupError` config preserves optional standalone servers by default while letting repository adapters require their declared servers. Repository-translated MCP clients enable that mode, so initial connection, discovery, or tool-registration failure rejects the candidate generation and rollback still closes the transport.
|
||||
|
||||
## Trust boundary
|
||||
|
||||
@@ -41,11 +41,11 @@ Model-visible behavior remains governed by the owning DSH seam. A repository ent
|
||||
- A TypeScript DSH Plugin can live in a GitHub repository, install ordinary npm dependencies, compile during `prepack`, and run without publishing the Plugin package to npm.
|
||||
- Static-only repository packages remain valid and retain import-free wrappers; adding `dsh.entry` opts that package into runtime code import.
|
||||
- A package build, dependency install, entry import, unmet service, or Plugin startup failure prevents the candidate generation from replacing the last good configuration.
|
||||
- The initial MCP connection can lengthen application startup, and a repository-declared server that is unavailable prevents that candidate generation from activating.
|
||||
- Initial MCP synchronization can lengthen application startup by the MCP SDK's per-request timeout, and a repository-declared server that is unavailable or cannot publish its complete tool generation prevents that candidate generation from activating.
|
||||
- Repository code receives host authority, so source review and immutable pinning are operational security requirements rather than optional hardening.
|
||||
|
||||
## Testing
|
||||
|
||||
Repository-format tests prepare and mount default-export code entries through the real Loader, observe an entry-owned service, remove the Loader row, and observe cleanup; they also retain skill/MCP preparation, containment, damaged-package, pending-service, and rollback coverage. MCP lifecycle tests require `apply` to settle only after initial tool publication, preserve opt-in contained connect failure, and prove strict startup rejection still closes the client.
|
||||
Repository-format tests prepare and mount default-export code entries through the real Loader, observe an entry-owned service, remove the Loader row, and observe cleanup; they also retain skill/MCP preparation, containment, damaged-package, pending-service, and rollback coverage. MCP lifecycle tests require `apply` to settle only after initial tool publication, preserve opt-in contained startup failure, and prove strict connection or tool-registration rejection still closes the client.
|
||||
|
||||
The Node 24 consumer acceptance uses the actual built `dsh run` command with a fresh DSH home and an authenticated private GitHub source pinned to the pull request's exact head SHA. The test packs the current repository Plugin build with the same private-field removal and workspace-dependency pinning used for publication, serves its packument and tarball from a job-local npm registry, and directs the Git package's ordinary scoped npm resolution there. That repository package obtains `dsh-plugin-prepare` from the simulated published dependency, installs its other pinned runtime and development dependencies, type-checks and bundles TypeScript during `prepack`, prepares a skill plus a stdio MCP server and `dsh.entry`, exposes the skill and MCP schema in the first real model request, executes the MCP tool, and lets the compiled Cordis entry append a second marker to the result observed in the following request. Registry and cache assertions require npm resolution to reach the simulated publication, source files to be absent from the packed installation, and both built modules, their installed dependency, copied assets, and generated wrapper to be present.
|
||||
+5
-5
@@ -16,13 +16,13 @@
|
||||
|
||||
包自行负责其 NPM 依赖和构建工具链。它声明已发布的 `@deepseek-ai/dsh-repository-plugin` 包以取得 `dsh-plugin-prepare` 可执行文件。`scripts.prepack` 是由包作者编写的非空命令,必须调用该依赖提供的辅助程序,但可以先运行 `tsc`、`tsdown` 或其他任意构建。DSH 不会注入辅助程序,也不会解析该 shell 程序或编译 repository 源码。辅助程序会在前序构建之后校验元数据,要求已配置入口解析到 `.dsh-plugin` 内的文件,校验并复制已声明的静态资源,再写入已准备的 `dsh-plugin.mjs` 包装层。已安装包必须保留包含该辅助命令的 `prepack` 声明;依赖、包装层或构建输出缺失会在缓存 generation 可用前导致失败。
|
||||
|
||||
生成的包装层先挂载 DSH 自有的静态运行时来处理 skill 和 MCP 定义,再动态导入显式入口、解包其导出并将其挂载为子级。两个子级都必须进入 Cordis `ACTIVE`;无法满足的 `inject` 或启动异常会拒绝 repository Loader 事务,而不会提交未激活的 generation。Loader 移除、替换失败和父级 dispose(资源释放)会一并撤销入口、skill 提供方、MCP client 及其 effect。
|
||||
生成的包装层先挂载 DSH 自有的静态运行时来处理 skill 和 MCP 定义,再动态导入显式入口、解包其导出并将其挂载为子级。包装层会静态声明已准备 manifest(元数据清单)所隐含的依赖;入口模块的额外 `inject` 只有在挂载时才会被发现,并且此时必须已存在于宿主组合中。两个子级都必须进入 Cordis `ACTIVE`;无法满足的 `inject` 或启动异常会拒绝 repository Loader 事务,而不会提交未激活的 generation。Loader 移除、替换失败和父级 dispose(资源释放)会一并撤销入口、skill 提供方、MCP client 及其 effect。
|
||||
|
||||
`dsh-mcp-client` 会在插件应用期间完成其初始连接和工具同步 promise。其入口必须是 `async function`,而不是返回 Promise 的普通函数:Cordis 会把带 prototype 的普通函数识别为 constructor,不会把 constructor 返回的 Promise 当作启动工作。因此,有效 server 的工具会在父级 repository 包装层激活前、一次性应用发起首个模型请求前就已存在。其 `failOnStartupError` 配置默认保留独立可选 server 的行为,同时允许 repository adapter 要求已声明 server 必须可用。Repository 转换出的 MCP client 会启用该模式,因此初始连接或发现失败会拒绝候选 generation,回滚仍会关闭 transport。
|
||||
`dsh-mcp-client` 会在插件应用期间完成其初始连接和工具同步 promise。其入口必须是 `async function`,而不是返回 Promise 的普通函数:Cordis 会把带 prototype 的普通函数识别为 constructor,不会把 constructor 返回的 Promise 当作启动工作。因此,有效 server 的工具会在父级 repository 包装层激活前、一次性应用发起首个模型请求前就已存在。其 `failOnStartupError` 配置默认保留独立可选 server 的行为,同时允许 repository adapter 要求已声明 server 必须可用。Repository 转换出的 MCP client 会启用该模式,因此初始连接、发现或工具注册失败会拒绝候选 generation,回滚仍会关闭 transport。
|
||||
|
||||
## 信任边界
|
||||
|
||||
精确 ref、源路径包含约束、清除名称符合凭据模式的环境变量、已准备的 manifest(元数据清单)和不可变缓存键,可以保护身份与组合完整性;它们不会为可执行包输入提供沙箱隔离。Repository 生命周期脚本、传递性 NPM 依赖、已编译入口和 spawn 的 MCP server 可以行使 DSH 进程可用的权限,以及它们所获 Cordis 服务授予的权限。因此,用户必须信任所选仓库,应当固定不可变 ref,并只授予 Git 获取源码所需的最小只读凭据。
|
||||
精确 ref、源路径包含约束、清除名称符合凭据模式的环境变量、已准备的 manifest 和不可变缓存键,可以保护身份与组合完整性;它们不会为可执行包输入提供沙箱隔离。Repository 生命周期脚本、传递性 NPM 依赖、已编译入口和 spawn 的 MCP server 可以行使 DSH 进程可用的权限,以及它们所获 Cordis 服务授予的权限。因此,用户必须信任所选仓库,应当固定不可变 ref,并只授予 Git 获取源码所需的最小只读凭据。
|
||||
|
||||
模型可见行为仍由所属 DSH seam 管理。repository 入口可以注册工具、提示词段落、策略、命令、agent(智能体)或其他 effect,但任何进入模型请求的内容仍须具有对应的 DSH 日志表示和生命周期清理。repository 格式授予代码加载能力;它不会削弱这些服务契约。
|
||||
|
||||
@@ -41,11 +41,11 @@
|
||||
- TypeScript DSH 插件可以存放在 GitHub 仓库中,安装普通 NPM 依赖,在 `prepack` 期间完成编译,并在无需把插件包发布到 NPM 的情况下运行。
|
||||
- 仅含静态贡献的 repository 包仍然有效,并保留无 import 包装层;添加 `dsh.entry` 会使该包选择启用运行时代码导入。
|
||||
- 包构建、依赖安装、入口导入、所需服务未满足或插件启动失败,都会阻止候选 generation 替换最后一个可用配置。
|
||||
- 初始 MCP 连接可能延长应用启动时间;repository 声明的 server 不可用时,该候选 generation 无法激活。
|
||||
- 初始 MCP 同步可能因 MCP SDK 的单次请求超时而延长应用启动时间;repository 声明的 server 不可用或无法发布完整工具 generation 时,该候选 generation 无法激活。
|
||||
- Repository 代码获得宿主权限,因此源码评审和锁定不可变 ref 是运行安全要求,而不是可选加固措施。
|
||||
|
||||
## 测试
|
||||
|
||||
repository 格式测试通过真实 Loader 准备并挂载使用 default export 的代码入口,观察入口自有服务,移除 Loader 配置项,再观察清理;测试还保留针对 skill/MCP 准备、路径包含约束、包损坏、等待服务和回滚的覆盖。MCP 生命周期测试要求 `apply` 只在初始工具发布后完成,保留选择收束连接失败的能力,并证明严格启动拒绝仍会关闭 client。
|
||||
repository 格式测试通过真实 Loader 准备并挂载使用 default export 的代码入口,观察入口自有服务,移除 Loader 配置项,再观察清理;测试还保留针对 skill/MCP 准备、路径包含约束、包损坏、等待服务和回滚的覆盖。MCP 生命周期测试要求 `apply` 只在初始工具发布后完成,保留可选择启用的启动失败收束行为,并证明严格连接拒绝或工具注册拒绝仍会关闭 client。
|
||||
|
||||
Node 24 消费方验收使用实际构建的 `dsh run` 命令、全新 DSH 主目录,以及锁定到 PR(Pull Request)的精确 head SHA 且经过认证的私有 GitHub 源。测试会采用发布时相同的移除 `private` 字段和固定 workspace 依赖版本流程,对当前 repository 插件构建进行打包;再由作业本地 NPM 注册表提供其 `packument` 与 tarball,并把 Git 包的常规 scoped NPM 解析指向该注册表。该 repository 包从模拟发布的依赖取得 `dsh-plugin-prepare`,安装其他固定版本的运行时依赖与开发依赖,在 `prepack` 期间对 TypeScript 进行类型检查和打包,准备一个 skill、一个 stdio MCP server 及 `dsh.entry`,在首个真实模型请求中暴露 skill 与 MCP schema,执行 MCP 工具,并让已编译 Cordis 入口向结果追加第二个标记,供后续请求观察。注册表与缓存断言要求 NPM 解析必须命中模拟发布,打包安装中不存在源码文件,同时必须存在两个已构建模块、其已安装依赖、复制资源和生成包装层。
|
||||
@@ -988,7 +988,7 @@ export interface StdioConfig {
|
||||
cwd: string
|
||||
/** Per-tool-call timeout in milliseconds. */
|
||||
toolCallTimeoutMs: number
|
||||
/** Fail plugin activation when the initial connection or tool discovery fails. */
|
||||
/** Fail plugin activation when the initial connection or tool synchronization fails. */
|
||||
failOnStartupError: boolean
|
||||
}
|
||||
|
||||
@@ -1008,7 +1008,7 @@ export interface StreamableHttpConfig {
|
||||
headers: Record<string, string>
|
||||
/** Per-tool-call timeout in milliseconds. */
|
||||
toolCallTimeoutMs: number
|
||||
/** Fail plugin activation when the initial connection or tool discovery fails. */
|
||||
/** Fail plugin activation when the initial connection or tool synchronization fails. */
|
||||
failOnStartupError: boolean
|
||||
}
|
||||
```
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/mcp/mcp-client/README.md
|
||||
README.md: c87255917a2def0aef938af9f2c910b65d5a96d5
|
||||
README.zh.md: 6fd6df39d7c5034795021d41de57f8500cbfd1c5
|
||||
README.md: 76d1271f6f7a3e9c959bdcf5e969906f25563c56
|
||||
README.zh.md: 49de996863ab16a46cd7ee82b13624523dbb853f
|
||||
@@ -44,7 +44,7 @@ The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same
|
||||
| `url` | http | yes | MCP server URL |
|
||||
| `headers` | http | no | Extra headers (e.g. auth tokens) |
|
||||
| `toolCallTimeoutMs` | both | no | Timeout per `callTool` invocation (default 60000) |
|
||||
| `failOnStartupError` | both | no | Reject plugin activation when the initial connection or tool discovery fails (default `false`) |
|
||||
| `failOnStartupError` | both | no | Reject plugin activation when initial connection or tool synchronization fails (default `false`) |
|
||||
|
||||
## Tool naming
|
||||
|
||||
@@ -57,8 +57,8 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`
|
||||
|
||||
## Behavior
|
||||
|
||||
- On connect: plugin activation awaits `listTools()` and registers each tool via `ctx.tools.register()` under its public name before the composition starts its first turn. Initial connection or discovery failure is always logged; it rejects activation when `failOnStartupError` is true and otherwise activates with no tools.
|
||||
- Listens for `notifications/tools/list_changed` → re-syncs; a failed re-sync keeps the previous generation registered.
|
||||
- On connect: plugin activation awaits `listTools()` and registers each tool via `ctx.tools.register()` under its public name before the composition starts its first turn. Initial connection, discovery, or registration failure is always logged; it rejects activation when `failOnStartupError` is true and otherwise activates with no tools.
|
||||
- Listens for `notifications/tools/list_changed` → re-syncs; a fetch-phase failure keeps the previous generation registered, while a registration conflict rolls back the attempted generation and leaves no tools from that server.
|
||||
- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server.
|
||||
- Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`.
|
||||
- Native/model rendering keeps the existing text projection: text blocks join with newlines while image, audio, resource, and unsupported blocks become placeholders.
|
||||
@@ -103,6 +103,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumption surface and are deferred.
|
||||
- **Startup timeout is inherited from the MCP SDK** — DSH does not yet expose a connection/discovery timeout. Each initialize or paginated `tools/list` request uses the SDK's 60-second default, so an unresponsive server or cursor chain can delay both activation and teardown while the initial synchronization settles.
|
||||
- **Crash recovery is manual** — transport closure does not auto-reconnect; registered tools can remain visible but fail against the closed transport until an HMR reload or Host restart.
|
||||
- **Native non-text rendering is lossy** — image, audio, and resource payloads become placeholders in model context even though the execution-local canonical value preserves their JSON blocks. Richer Native multimedia projection is deferred.
|
||||
- **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset.
|
||||
@@ -44,7 +44,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
|
||||
| `url` | http | 是 | MCP 服务器 URL |
|
||||
| `headers` | http | 否 | 额外标头(例如认证 token) |
|
||||
| `toolCallTimeoutMs` | 两者 | 否 | 每次 `callTool` 调用的超时(默认 60000) |
|
||||
| `failOnStartupError` | 两者 | 否 | 初始连接或工具发现失败时拒绝插件激活(默认 `false`) |
|
||||
| `failOnStartupError` | 两者 | 否 | 初始连接或工具同步失败时拒绝插件激活(默认 `false`) |
|
||||
|
||||
## 工具命名
|
||||
|
||||
@@ -57,8 +57,8 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
|
||||
|
||||
## 行为
|
||||
|
||||
- 连接时:插件激活会等待 `listTools()`,并在组合开始首个轮次前通过 `ctx.tools.register()` 以公开名称注册每个工具。初始连接或发现失败始终会记录日志;`failOnStartupError` 为 true 时拒绝激活,否则插件仍会激活但不注册工具。
|
||||
- 监听 `notifications/tools/list_changed` → 重新同步;同步失败时保留上一世代的注册。
|
||||
- 连接时:插件激活会等待 `listTools()`,并在组合开始首个轮次前通过 `ctx.tools.register()` 以公开名称注册每个工具。初始连接、发现或注册失败始终会记录日志;`failOnStartupError` 为 true 时拒绝激活,否则插件仍会激活但不注册工具。
|
||||
- 监听 `notifications/tools/list_changed` → 重新同步;获取阶段失败时保留上一世代的注册,注册冲突则会回滚本次尝试的世代,并且不保留该服务器的任何工具。
|
||||
- 工具执行:`client.callTool({ name: rawName, arguments }, { signal })`,支持超时 + 中止;公开名称绝不会发给服务器。
|
||||
- 规范成功值是 `{ content: JsonValue[], structuredContent? }`;完整的 JSON MCP 块会保留给编程调用方。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇会回退为不受约束的 `JsonValue`。
|
||||
- Native/模型渲染保留现有文本投影:文本块以换行连接,图片、音频、资源和不受支持的块会变成占位符。
|
||||
@@ -103,6 +103,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只桥接 MCP 的工具能力**:资源和提示词没有 harness 消费接口,暂缓实现。
|
||||
- **启动超时继承自 MCP SDK**:DSH 尚未公开连接/发现超时。每次 initialize 请求或分页 `tools/list` 请求都使用 SDK 默认的 60 秒,因此在初始同步完成期间,无响应的 server 或 cursor chain 可能同时延迟激活与 teardown。
|
||||
- **崩溃恢复需要手动触发**:传输关闭后不会自动重新连接;已注册工具可能仍然可见,但会因传输已关闭而调用失败,直到 HMR 重载或重启 Host。
|
||||
- **Native 非文本渲染有损**:图片、音频与资源载荷在模型上下文中会变成占位符,即使执行局部的规范值保留了其 JSON 块。更丰富的 Native 多媒体投影暂缓实现。
|
||||
- **不强制执行不受支持的 MCP 输出 schema**:已声明 schema 使用 harness 子集之外的词汇时,`structuredContent` 会回退到 `JsonValue`。
|
||||
@@ -72,7 +72,7 @@ export interface StdioConfig {
|
||||
cwd: string
|
||||
/** Per-tool-call timeout in milliseconds. */
|
||||
toolCallTimeoutMs: number
|
||||
/** Fail plugin activation when the initial connection or tool discovery fails. */
|
||||
/** Fail plugin activation when the initial connection or tool synchronization fails. */
|
||||
failOnStartupError: boolean
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export interface StreamableHttpConfig {
|
||||
headers: Record<string, string>
|
||||
/** Per-tool-call timeout in milliseconds. */
|
||||
toolCallTimeoutMs: number
|
||||
/** Fail plugin activation when the initial connection or tool discovery fails. */
|
||||
/** Fail plugin activation when the initial connection or tool synchronization fails. */
|
||||
failOnStartupError: boolean
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
)
|
||||
|
||||
const opts = {
|
||||
registrationFailure: 'contain' as const,
|
||||
serverName: config.serverName,
|
||||
toolCallTimeoutMs: config.toolCallTimeoutMs,
|
||||
}
|
||||
@@ -166,7 +167,10 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
const ready = (async () => {
|
||||
await client.connect(transport)
|
||||
|
||||
let disposers = await syncTools(client, ctx, opts, new Map())
|
||||
let disposers = await syncTools(client, ctx, {
|
||||
...opts,
|
||||
registrationFailure: config.failOnStartupError ? 'throw' : 'contain',
|
||||
}, new Map())
|
||||
|
||||
client.setNotificationHandler(
|
||||
ToolListChangedNotificationSchema,
|
||||
@@ -184,7 +188,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
|
||||
return { getDisposers: () => disposers }
|
||||
})().catch((error: unknown) => {
|
||||
ctx.logger.error(`mcp-client(${config.serverName}): failed to connect: ${String(error)}`)
|
||||
ctx.logger.error(`mcp-client(${config.serverName}): startup failed: ${String(error)}`)
|
||||
return { getDisposers: () => new Map<string, () => void>(), error }
|
||||
})
|
||||
|
||||
@@ -196,6 +200,6 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
|
||||
const outcome = await ready
|
||||
if ('error' in outcome && config.failOnStartupError) {
|
||||
throw new Error(`mcp-client(${config.serverName}): initial connection or tool discovery failed`, { cause: outcome.error })
|
||||
throw new Error(`mcp-client(${config.serverName}): initial connection or tool synchronization failed`, { cause: outcome.error })
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import type { JsonSchemaNode, JsonValue } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** Resolved options relevant to tool bridging. */
|
||||
export interface ToolBridgeOptions {
|
||||
/** Whether a registry conflict is contained or rejects this synchronization. */
|
||||
registrationFailure: 'contain' | 'throw'
|
||||
serverName: string
|
||||
toolCallTimeoutMs: number
|
||||
}
|
||||
@@ -111,8 +113,9 @@ export function publicToolName(serverName: string, rawName: string): string {
|
||||
* 2. Swap: dispose the previous generation, register the new one. A registry
|
||||
* conflict here can only mean a foreign registration squats on this
|
||||
* server's `mcp__<serverName>__` namespace — the partial generation is
|
||||
* rolled back (zero tools from this server), the error is logged, and an
|
||||
* empty map is returned.
|
||||
* rolled back (zero tools from this server) and logged. Initial strict
|
||||
* synchronization may propagate the conflict so its parent transaction
|
||||
* rejects; ordinary clients and later re-syncs return an empty map.
|
||||
*
|
||||
* @param client - Connected MCP Client instance used to list and call tools.
|
||||
* @param ctx - Cordis context providing the `tools` service for registration.
|
||||
@@ -164,6 +167,7 @@ export async function syncTools(
|
||||
// sees either the full generation or none of it — never a partial set.
|
||||
for (const dispose of disposers.values()) dispose()
|
||||
ctx.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`)
|
||||
if (opts.registrationFailure === 'throw') throw error
|
||||
return new Map()
|
||||
}
|
||||
return disposers
|
||||
|
||||
@@ -229,7 +229,7 @@ describe('apply (plugin lifecycle)', () => {
|
||||
await expect(apply(ctx, {
|
||||
...stdioConfig,
|
||||
failOnStartupError: true,
|
||||
})).rejects.toThrow('initial connection or tool discovery failed')
|
||||
})).rejects.toThrow('initial connection or tool synchronization failed')
|
||||
|
||||
expect(mockListTools).not.toHaveBeenCalled()
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
@@ -237,6 +237,28 @@ describe('apply (plugin lifecycle)', () => {
|
||||
expect(mockClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects strict startup when the initial tool generation cannot be registered', async () => {
|
||||
ctx.tools.register({
|
||||
name: 'mcp__srv__remote',
|
||||
description: 'Foreign squatter',
|
||||
parameters: { type: 'object' },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value as string }],
|
||||
},
|
||||
execute: async () => 'foreign',
|
||||
})
|
||||
|
||||
await expect(apply(ctx, {
|
||||
...stdioConfig,
|
||||
failOnStartupError: true,
|
||||
})).rejects.toThrow('initial connection or tool synchronization failed')
|
||||
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
expect(mockClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-syncs tools on ToolListChanged notification', async () => {
|
||||
await apply(ctx, stdioConfig)
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ async function mountRegistry(): Promise<Context> {
|
||||
}
|
||||
|
||||
const defaultOpts: ToolBridgeOptions = {
|
||||
registrationFailure: 'contain',
|
||||
serverName: 'srv',
|
||||
toolCallTimeoutMs: 60_000,
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/self-modification/repository-plugin/README.md
|
||||
README.md: 3a6478d5cca05684e80ce0854cb6e0b5d2f88bd4
|
||||
README.zh.md: 274d11c96cb61d4d9fa4b837433994248c94bff4
|
||||
README.md: 0df0680cef873d9f7078cb63d1c803e5c323f07c
|
||||
README.zh.md: 04e5f8f81ba1b3e788775e9deaff6bb0b6dd1a48
|
||||
@@ -64,13 +64,13 @@ During exact Git installation, DSH's bundled pnpm installs the selected package
|
||||
|
||||
## Runtime composition
|
||||
|
||||
Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates its prepared static manifest to that builtin, then imports and mounts `dsh.entry` when declared. The entry is an ordinary Cordis child Plugin: its own `inject` gates activation, startup failures reject the repository generation, and all of its effects disappear on Loader removal or rollback. The runtime likewise validates every declared skill root as an existing in-package directory before mounting — a package whose generated outputs were dropped by `files`/`.npmignore` or damaged in cache fails instead of silently losing contributions. Repository skill roots mount as uniquely named `dsh-skill-local` providers with default project/user roots excluded and watching disabled; cached package generations are immutable.
|
||||
Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates its prepared static manifest to that builtin, then imports and mounts `dsh.entry` when declared. The wrapper can statically gate only the `loader`, `skills`, and `tools` services implied by the prepared manifest; the entry's own `inject` is discovered when that child is mounted. The entry must reach `ACTIVE`, so a missing entry-only service or startup failure rejects the repository generation instead of committing an inert child, and all effects disappear on Loader removal or rollback. The runtime likewise validates every declared skill root as an existing in-package directory before mounting — a package whose generated outputs were dropped by `files`/`.npmignore` or damaged in cache fails instead of silently losing contributions. Repository skill roots mount as uniquely named `dsh-skill-local` providers with default project/user roots excluded and watching disabled; cached package generations are immutable.
|
||||
|
||||
## Common MCP format
|
||||
|
||||
The `.mcp.json` root is `{ "mcpServers": { ... } }`. A stdio entry accepts only `type: "stdio"` (optional), `command`, `args`, and `env`; an HTTP entry accepts only `type: "http"`, `url`, and `headers`. String values support exact `${NAME}` process-environment expansion at Plugin load, and a missing name fails that load. HTTP URLs become the existing MCP client's `streamable-http` transport; stdio entries use the prepared package directory as `cwd`.
|
||||
|
||||
Unknown fields reject, including OAuth and `auth` objects. There is no `CLAUDE_PLUGIN_ROOT` expansion or compatibility layer. After translation, the existing `dsh-mcp-client` exclusively owns transport creation, connection diagnostics, tool synchronization, calls, and disconnect lifecycle. Repository-declared servers enable its strict startup mode: Plugin activation waits for the initial connection and tool discovery, so the first model request observes a successful initial tool generation, while a network, child-process, or discovery failure rejects the candidate repository generation instead of silently activating without its declared tools.
|
||||
Unknown fields reject, including OAuth and `auth` objects. There is no `CLAUDE_PLUGIN_ROOT` expansion or compatibility layer. After translation, the existing `dsh-mcp-client` exclusively owns transport creation, connection diagnostics, tool synchronization, calls, and disconnect lifecycle. Repository-declared servers enable its strict startup mode: Plugin activation waits for the initial connection and tool synchronization, so the first model request observes a fully registered initial tool generation, while a network, child-process, discovery, or registration failure rejects the candidate repository generation instead of silently activating without its declared tools.
|
||||
|
||||
## Export shape
|
||||
|
||||
@@ -123,5 +123,6 @@ Stable registrations preserve the owning surface's normal prefix behavior. Loadi
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No code sandbox** — `dsh.entry`, npm dependencies, and package lifecycle scripts execute with the DSH host's authority; repository trust is mandatory.
|
||||
- **Entry-only service dependencies are not pre-gated** — the generated wrapper cannot declare an entry module's `inject` before importing it. Any service beyond those implied by Skills or MCP must already exist when the wrapper mounts the entry, or that repository generation rejects.
|
||||
- **No MCP authentication protocol** — static headers may use environment expansion, but OAuth-bearing definitions reject and private-server login flows are not implemented here.
|
||||
- **Generated assets are immutable runtime input** — repository cache generations are not watched; source, ref, path, or configuration must select another prepared generation.
|
||||
@@ -64,13 +64,13 @@ Git 传输使用宿主的常规 Git 认证。公共仓库无需凭据;私有
|
||||
|
||||
## 运行时组合
|
||||
|
||||
加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装层都把已准备的静态 manifest(元数据清单)委托给该 builtin,再在声明了 `dsh.entry` 时导入并挂载该入口。入口是普通的 Cordis 子插件:其自有 `inject` 会门控激活,启动失败会拒绝 repository generation,Loader 移除或回滚时,其所有 effect 都会消失。运行时同样会在挂载前校验每个声明的 skill 根都是包内实际存在的目录——生成输出因 `files`/`.npmignore` 被丢弃或在缓存中损坏的包会加载失败,而不是静默丢失贡献。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存包 generation 是不可变的。
|
||||
加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装层都把已准备的静态 manifest(元数据清单)委托给该 builtin,再在声明了 `dsh.entry` 时导入并挂载该入口。包装层只能静态门控已准备 manifest 所隐含的 `loader`、`skills` 与 `tools` 服务;入口自身的 `inject` 要到挂载该子级时才会发现。入口必须进入 `ACTIVE`,因此缺少入口专用服务或启动失败时,会拒绝 repository generation,而不会提交未激活的子级;Loader 移除或回滚时,所有 effect 都会消失。运行时同样会在挂载前校验每个声明的 skill 根都是包内实际存在的目录——生成输出因 `files`/`.npmignore` 被丢弃或在缓存中损坏的包会加载失败,而不是静默丢失贡献。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存包 generation 是不可变的。
|
||||
|
||||
## 通用 MCP 格式
|
||||
|
||||
`.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"`、`command`、`args` 和 `env`;HTTP 条目只接受 `type: "http"`、`url` 和 `headers`。字符串值在插件加载时支持严格的 `${NAME}` 进程环境变量展开;缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transport;stdio 条目以已准备的包目录作为 `cwd`。
|
||||
|
||||
未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期。Repository 声明的 server 会启用其严格启动模式:插件激活会等待初始连接与工具发现,因此首个模型请求会看到成功的初始工具 generation;网络、子进程或发现失败则会拒绝候选 repository generation,而不是在缺少已声明工具的情况下静默激活。
|
||||
未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期。Repository 声明的 server 会启用其严格启动模式:插件激活会等待初始连接与工具同步,因此首个模型请求会看到已完整注册的初始工具 generation;网络、子进程、发现或注册失败则会拒绝候选 repository generation,而不是在缺少已声明工具的情况下静默激活。
|
||||
|
||||
## 导出形状
|
||||
|
||||
@@ -123,5 +123,6 @@ Namespace 插件:具名导出 `name`/`inject`/`apply`、准备阶段常量
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有代码沙箱**:`dsh.entry`、NPM 依赖和包生命周期脚本以 DSH 宿主权限执行;必须信任该 repository。
|
||||
- **入口专用服务依赖不会预先门控**:生成的包装层无法在导入入口模块前声明其 `inject`。除 skill 或 MCP 隐含的服务外,其他任何服务在包装层挂载入口时都必须已经存在,否则该 repository generation 会被拒绝。
|
||||
- **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。
|
||||
- **生成资源是不可变运行时输入**:repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。
|
||||
@@ -152,6 +152,7 @@ function wrapperSource(manifest: PreparedPluginManifest): string {
|
||||
return [
|
||||
'// Generated by dsh-plugin-prepare. Do not edit.',
|
||||
`const manifest = ${JSON.stringify(manifest)}`,
|
||||
'// Value mirror: Cordis const enum FiberState.ACTIVE; keep aligned with dsh-repository-plugin source.ts.',
|
||||
'const FIBER_ACTIVE = 2',
|
||||
`export const name = ${JSON.stringify(manifest.name)}`,
|
||||
`export const inject = ${JSON.stringify(inject)}`,
|
||||
|
||||
@@ -80,7 +80,11 @@ async function assertInstalledPackageMetadata(directory: string): Promise<void>
|
||||
}
|
||||
const result = installedPackageSchema.safeParse(value)
|
||||
if (!result.success) {
|
||||
throw new Error(`installed DSH plugin package must declare a non-empty scripts.prepack that invokes ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}:\n${z.prettifyError(result.error)}`)
|
||||
throw new Error([
|
||||
`installed DSH plugin package must declare a non-empty scripts.prepack that invokes ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}:`,
|
||||
z.prettifyError(result.error),
|
||||
'Clear the matching repository cache generation before retrying the same source, or select a different exact source/ref/path after fixing the package.',
|
||||
].join('\n'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -330,7 +330,7 @@ describe('prepared repository plugin Loader composition', () => {
|
||||
await ctx.plugin(RepositoryPlugin)
|
||||
await expect(ctx.loader.create({
|
||||
name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href,
|
||||
})).rejects.toThrow('initial connection or tool discovery failed')
|
||||
})).rejects.toThrow('initial connection or tool synchronization failed')
|
||||
expect(ctx.tools.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -593,6 +593,12 @@ describe('configured GitHub repository sources', () => {
|
||||
message: expect.stringContaining('must declare a non-empty scripts.prepack') as string,
|
||||
}) as Error,
|
||||
})
|
||||
await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#old&path:/.dsh-plugin'))
|
||||
.rejects.toMatchObject({
|
||||
cause: expect.objectContaining({
|
||||
message: expect.stringContaining('Clear the matching repository cache generation') as string,
|
||||
}) as Error,
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user