diff --git a/.gitignore b/.gitignore index c566c2d2a..8bbc960c3 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,4 @@ packages/docs/dev/colhover-out/ # Loop V1 内部设计文档仅保留在本地 workspace,不纳入版本库 docs/loop-v1/ .omx +.idea/ \ No newline at end of file diff --git a/.i18n/scan-config.json b/.i18n/scan-config.json index 4eba45643..bc050dcb8 100644 --- a/.i18n/scan-config.json +++ b/.i18n/scan-config.json @@ -36,6 +36,30 @@ "path": "packages/docs/src/editor/fontFamilies.ts", "reason": "Toolbar font-family presets. The only CJK literals are the localized family-name aliases inside the CSS font-family values (e.g. \"微软雅黑\", \"宋体\") that must byte-match the native font name so browsers matching a font by its Chinese name still render it; they are font resource identifiers, not translatable UI copy (mirrors export/docx/styles.ts). The user-facing display name is localized separately via the labelKey i18n keys resolved with t()." }, + { + "path": "packages/dmworkmcp/src/mock/mcpMock.ts", + "reason": "MCP Market demo fixtures (server names, slogans, descriptions, FAQ, notes). This is sample content data returned by the mock service layer, replaced wholesale by the real backend response later; it is not translatable UI chrome and has no i18n namespace key to bind to." + }, + { + "path": "packages/dmworkmcp/src/api/quickStartTemplates.ts", + "reason": "Quick-start snippet generators. The CJK literals are fragments of the copy-ready prompt/CLI/JSON text (e.g. 名称/传输方式/鉴权 labels inside the generated instruction) that must byte-match what the user pastes into an agent client; they are generated code/prompt content, not translatable UI chrome." + }, + { + "path": "packages/dmworkskillmarket/src/api/mockData.ts", + "reason": "Skill Market demo fixtures (category names, skill names, slogans, prompts, tags). Sample content data returned by the mock service layer, replaced wholesale by the real backend response later; not translatable UI chrome. Same category as dmworkmcp/src/mock/mcpMock.ts." + }, + { + "path": "packages/dmworkskillmarket/src/__mocks__/dmworkBase.tsx", + "reason": "Vitest __mocks__ stub for @octo/base — provides a minimal fake t() and WKModal-shaped closer used only in unit tests. The single CJK literal ('关闭') is the test-mock modal close label and never surfaces in production UI." + }, + { + "path": "packages/dmworkskillmarket/src/utils/installPrompt.ts", + "reason": "Agent prompt template for the octo-cli install flow. The CJK literals are fragments of the copy-ready prompt text that must byte-match what the user pastes into an agent client (Skill ID / Space ID / API 地址 labels + step-by-step instructions); they are generated agent-facing content, not translatable UI chrome. Same category as dmworkmcp/src/api/quickStartTemplates.ts." + }, + { + "path": "packages/dmworkskillmarket/src/utils/botPublishPrompt.ts", + "reason": "Agent prompt template for the Bot publish flow. Same rationale as installPrompt.ts above — generated prompt content pasted into an agent client, must byte-match; not translatable UI chrome." + }, { "path": "packages/dmworksummary/src/__mocks__/handlers.ts", "reason": "MSW test fixture data with sample user names, titles, and message bodies; not UI copy." diff --git a/apps/web/.env.example b/apps/web/.env.example index 5116ae514..a000e8a97 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -2,3 +2,8 @@ # Used by Vite dev server proxy and Tauri/Electron builds # Example: https://api.example.com (not https://api.example.com/api/v1/) VITE_API_URL=https://api.example.com + +# Marketplace (octo-marketplace) service URL for local dev. +# The Vite proxy rewrites /market/api/v1/* → target/api/v1/* +# For local development with octo-marketplace running on :8092: +# VITE_MARKET_API_URL=http://127.0.0.1:8092 diff --git a/apps/web/package.json b/apps/web/package.json index 7271fc1f4..fa93dac84 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -56,6 +56,8 @@ }, "dependencies": { "@dmwork/appbot": "workspace:*", + "@dmwork/mcp": "workspace:*", + "@dmwork/skillmarket": "workspace:*", "@dmwork/summary": "workspace:*", "@douyinfe/semi-icons": "^2.93.0", "@douyinfe/semi-ui": "^2.93.0", @@ -80,6 +82,7 @@ "electron-screenshots": "^0.5.26", "electron-updater": "^6.1.8", "lucide-react": "^0.577.0", + "mathlive": "^0.104.2", "ms": "^2.1.3", "ogl": "^1.0.11", "react": "^18.3.1", diff --git a/apps/web/src/Components/InviteLanding/index.tsx b/apps/web/src/Components/InviteLanding/index.tsx index e34dd38b2..c09d50320 100644 --- a/apps/web/src/Components/InviteLanding/index.tsx +++ b/apps/web/src/Components/InviteLanding/index.tsx @@ -275,11 +275,16 @@ export default class InviteLanding extends Component { return
  • { vm.currentMenus = menus if (menus.onPress) { + // Sync the URL before firing the custom + // onPress. Some menu items only swap the + // right pane in onPress (e.g. Summary / + // Skill market) and never touch the + // address bar themselves — without this + // sync the URL stays on the previous + // route, so refresh / copied links / + // browser history reopen the wrong + // module (PR#851 Jerry-Xin 02:22 P1). + // Mirrors the desktop-path NavRail + // handler in Main/index.tsx. WKApp.route.syncPath(menus.routePath) menus.onPress() } else { @@ -32,4 +43,4 @@ export class TabLowScreen extends Component { } -} +} \ No newline at end of file diff --git a/apps/web/src/Pages/Main/vm.ts b/apps/web/src/Pages/Main/vm.ts index 229e167e7..1bc362455 100644 --- a/apps/web/src/Pages/Main/vm.ts +++ b/apps/web/src/Pages/Main/vm.ts @@ -68,16 +68,24 @@ export default class MainVM extends ProviderListener { this.syncMenuFromBrowserPath(); }; + private findMenuForRoute(routePath: string): Menus | undefined { + return this.menusList + .filter((menus) => { + if (menus.routePath === routePath) return true; + if (menus.routePath === "/") return false; + return routePath.startsWith(`${menus.routePath}/`); + }) + .sort((a, b) => b.routePath.length - a.routePath.length)[0]; + } + didMount(): void { let found = false; const bootPath = normalizeRoutePath(window.location.pathname || WKApp.route.currentPath); if (bootPath) { - for (const menus of this.menusList) { - if (menus.routePath === bootPath) { - this.currentMenus = menus; - found = true; - break; - } + const menus = this.findMenuForRoute(bootPath); + if (menus) { + this.currentMenus = menus; + found = true; } } // 默认选中第一个菜单(消息模块) @@ -193,7 +201,7 @@ export default class MainVM extends ProviderListener { private syncMenuFromBrowserPath(): boolean { const routePath = normalizeRoutePath(window.location.pathname); - const target = this.menusList.find((menus) => menus.routePath === routePath); + const target = this.findMenuForRoute(routePath); if (!target) { if (routePath !== "/") { this._pendingRouteActivation = routePath; diff --git a/apps/web/src/__tests__/docsDeepLinkCapture.test.ts b/apps/web/src/__tests__/docsDeepLinkCapture.test.ts index 529e245dc..689551a95 100644 --- a/apps/web/src/__tests__/docsDeepLinkCapture.test.ts +++ b/apps/web/src/__tests__/docsDeepLinkCapture.test.ts @@ -5,9 +5,9 @@ import * as path from 'path' * Regression for the forwarded-doc deep-link capture (feature #511, XIN-328 / XIN-332 / XIN-333). * * The forwarded-doc card link opens a new tab at `/docs?...&doc=`, but the octo host's - * RouteManager / host route normalization can wipe `?doc=` before the code-split docs chunk - * mounts — XIN-332 proved DocsModule.init() runs AFTER that wipe on device. - * The fix moves the primary capture into an inline + + + +

    MCP 市场端到端集成测试报告

    +

    跨端全流程:admin 建系统 MCP → web 查看 → web 自建/编辑/删除 → admin 编辑/删除,每步 UI 截图。

    + +
    +
    +
    测试日期
    2026-07-15
    +
    被测系统
    +
    +
    octo-web · http://localhost:3000
    +
    octo-admin · http://localhost:3101
    +
    octo-marketplace · http://localhost:8092
    +
    +
    测试账号
    +
    +
    web: Nancy (uid 7edea73a...)
    +
    admin: superAdmin
    +
    +
    覆盖场景
    6 组(A-F),共 18 步截图
    +
    +
    + + + + +
    +

    Scenario A · admin 建立系统 MCP

    +

    admin 端走完整 3 步向导,把「GitHub Ops」这条系统 MCP 从零建到落库并出现在管理列表中。

    + +
    +
    A1
    +
    +

    admin 空态PASS

    +

    清库后进入「系统 MCP」页,空态文案与新建 CTA 就位。

    +admin 空态 +
    断言: 表头「名称/分类/标签/工具数/创建者」显示;空态「还没有系统 MCP…」;右上角「+ 新建系统 MCP」按钮可点。
    +
    +
    + +
    +
    A2
    +
    +

    打开向导 · Step 1 空态PASS

    +

    Steps 头「基本信息 → 接入配置 → 文档说明」,Step 1 字段:图标预览 + 名称* + 图标 emoji/URL + 服务标识 slug + 分类 + 标签 + 简介。

    +admin wizard step 1 空 +
    断言: 顶部 Steps 编号 1/2/3;「服务标识 (slug)」输入框存在带 hint「生成 mcpServers JSON 时用作 key…」;名称带红色 * 必填标记。
    +
    +
    + +
    +
    A3
    +
    +

    Step 1 填字段 · slug 自动生成PASS

    +

    填写:名称=GitHub Ops、图标=🐙、标签=开发工具(回车 pill)、简介=通过 GitHub API…。slug 自动派生 github-ops

    +admin step 1 已填 +
    断言: slug 自动填充;标签变为 pill;图标预览显示章鱼 emoji。
    +
    +
    + +
    +
    A4
    +
    +

    Step 2 · 接入配置 + 工具清单PASS

    +

    传输方式=远程 HTTP,URL=https://api.github.com/mcp,认证=无。工具清单加 2 条:list_reposcreate_issue

    +admin step 2 +
    断言: 「+ 新增一条」按钮动态添加行;每行输入独立;工具序号 #1 #2 显示。
    +
    +
    + +
    +
    A5
    +
    +

    Step 3 · 文档说明(system 无可见范围)PASS

    +

    3 个可选段:使用示例 / 常见问题 / 注意事项。system MCP 强制 visibility=system,故不展示公开/仅自己选项。

    +admin step 3 +
    断言: 3 个段各带 desc + 空态引导「点击「新增一条」…」;底部「提交」按钮就位;无可见范围控件。
    +
    +
    + +
    +
    A6
    +
    +

    提交 · 记录出现在列表PASS

    +

    点击「提交」→ Toast「已创建」→ Modal 关闭 → 列表新增一行。

    +admin list after create +
    断言: 行显示章鱼图标 + GitHub Ops + 副标题 + 分类「开发工具」pill + 标签 pill + 工具数=2 + 创建者=Developer;DB 里 visibility=system。
    +
    +
    +
    + + +
    +

    Scenario B · web 端看到 admin 建的系统 MCP

    +

    切换到 octo-web MCP 市场,验证 admin 端建立的系统 MCP 对普通用户可见。

    + +
    +
    B1
    +
    +

    web 全部 tab 展示系统 MCPPASS

    +

    用户 Nancy 进入 MCP 市场,全部 tab 展示 1 条:GitHub Ops(admin 刚建的)。

    +web market shows system MCP +
    断言: 卡片显示章鱼图标 + 名称 + 分类 pill + 简介 + 工具数 2 + 「查看详情」;分类过滤条只显示「开发工具 1」。
    +
    +
    + +
    +
    B2
    +
    +

    web 打开系统 MCP 详情PASS

    +

    点击卡片 → 详情弹窗,展示快速接入模板、创建者信息、工具清单。

    +web detail of system mcp +
    断言: 详情标题、创建者 @Developer、快速接入代码块(含 name/transport/url)、Copy 按钮、工具清单段全部渲染。非 owner 用户看不到编辑/删除
    +
    +
    +
    + + +
    +

    Scenario C · web 用户建立自己的 MCP

    +

    Nancy 在自己账号下新建一条 MCP。走 web 的 3 步向导(与 admin 结构一致)。

    + +
    +
    C1
    +
    +

    Step 1 填名字PASS

    +

    名称=My Note Taking。web 与 admin 同款 3 步向导。

    +web wizard step 1 +
    断言: 顶部步骤条 1/2/3;服务标识(slug)字段存在(可与 admin 侧对齐);简介说明存在。
    +
    +
    + +
    +
    C2
    +
    +

    走完 Step 2/3 提交 · 列表出现新记录PASS

    +

    填 URL https://note.example.com/mcp,提交。Toast「创建成功」(BUG-01 fix 后不再是「(Mock)」)。

    +web list after user create +
    断言: 「全部」tab 现在 2 条:My Note Taking(用户)+ GitHub Ops(系统);两条并列展示,混合渲染正确;分类计数「全部 2」「开发工具 2」。
    +
    +
    +
    + + +
    +

    Scenario D · web 用户编辑自己的 MCP

    +

    切「我的」tab,打开自建 MCP 详情,编辑名称并保存。验证 owner-only 权限 + 向导 hydrate 完整性。

    + +
    +
    D1
    +
    +

    「我的」tab · 详情显示编辑/删除PASS

    +

    从「我的」tab 打开 My Note Taking → 详情右下角出现「删除」+「编辑」按钮(canManage = mode==='mine')。

    +web detail own with edit/delete +
    断言: 详情标题「My Note Taking」;footer 显示 删除(红色)+编辑(黑色) 双按钮,位置右下。
    +
    +
    + +
    +
    D2
    +
    +

    编辑向导 · Step 1 字段完整 hydratePASS

    +

    点编辑 → 向导打开,Step 1 名称字段预填「My Note Taking」,修改为「My Note Taking (renamed)」。

    +web edit wizard hydrated +
    断言: 向导标题变「编辑 MCP」;名称已 hydrate;service 标识、简介、分类等全部按记录预填。
    +
    +
    + +
    +
    D3
    +
    +

    保存 · 列表卡片刷新PASS

    +

    Step 3 提交 → Toast「已保存」→ Modal 关 → 列表卡片名字变成「My Note Taking (renamed)」。

    +web list after edit +
    断言: Toast「已保存」显示;卡片名字已更新;「我的」tab 计数 1。
    +
    +
    +
    + + +
    +

    Scenario E · web 用户删除自己的 MCP

    + +
    +
    E1
    +
    +

    详情内联删除确认PASS

    +

    在详情内点「删除」→ 内联展开 [取消][确认删除] 二次确认,无系统 confirm 弹窗。

    +web inline delete confirm +
    断言: 删除按钮消失,替代为「取消」+「确认删除」两个按钮;「确认删除」明显为危险色调。
    +
    +
    + +
    +
    E2
    +
    +

    确认后列表刷新 · 我的 tab 变空PASS

    +

    点确认 → Toast「已删除」→ Modal 关 → 我的 tab 变空态。

    +web empty after delete +
    断言: 「我的」tab「没有匹配的 MCP 服务」空态;「全部」tab 仍有 GitHub Ops(系统 MCP 未受影响)。
    +
    +
    +
    + + +
    +

    Scenario F · admin 编辑并删除系统 MCP

    +

    回到 admin 端,编辑 GitHub Ops 名称并保存,然后删除。验证跨端一致的 CRUD。

    + +
    +
    F1
    +
    +

    编辑向导 · 字段完整 hydratePASS

    +

    admin 点行 → Drawer → 编辑 → 向导 Step 1 预填 GitHub Ops → 修改名称为「GitHub Ops (更新版)」。BUG-04(旧版 antd Form hydrate 时序)在重写后天生消失。

    +admin edit hydrate +
    断言: Step 1 name/icon/slug/tag/slogan 全部预填正确;无「请填写…」的假 validation 报错。
    +
    +
    + +
    +
    F2
    +
    +

    Drawer 内联删除确认PASS

    +

    保存后回列表 → 再点行 → Drawer 删除 → 内联「取消 / 确认删除」。

    +admin inline delete +
    断言: Drawer footer 展开为 [取消][确认删除] 二次确认,与 web 端交互一致。
    +
    +
    + +
    +
    F3
    +
    +

    确认后列表变空PASS

    +

    确认删除 → Toast「已删除」→ Drawer 关 → 列表空态。

    +admin empty after delete +
    断言: 列表空态文案回来;DB WHERE visibility='system' AND deleted_at IS NULL 结果 0。
    +
    +
    +
    + + +
    +

    汇总

    + + + + + + + + + + + +
    Scenario步数PassFail关键验证
    A · admin 建系统 MCP6603 步向导 + slug 自动派生 + 动态工具清单 + visibility=system 强制
    B · web 看到 admin 建的220跨端可见 + 非 owner 无编辑/删除按钮
    C · web 自建 MCP220Toast「创建成功」(BUG-01 fix 生效)+ 系统 + 用户混合列表
    D · web 编辑330「我的」tab 展 edit/delete 按钮 + 向导 hydrate 完整
    E · web 删除220内联二次确认 + 我的空态但系统 MCP 未受影响
    F · admin 编辑并删除330BUG-04 hydrate 修复生效 + 交互与 web 端一致
    合计18180
    + +

    本次链路修复的 bug

    +
      +
    • BUG-01(前修) mcp.create.success 遗留「(Mock)」→ 已改「创建成功」R5 确认
    • +
    • BUG-02(前修) system MCP 允许重名(NULL space_id 让 UNIQUE 失效)→ 加 service 层预检 + repo Query + 4 单测Fixed
    • +
    • BUG-03(前修) admin 图标 label 重复「(可选)」→ i18n 修正Fixed
    • +
    • BUG-04(前修) admin 旧版 FormModal 编辑时 URL/tools 未 hydrate → 重写用 useState,天生绕过Fixed
    • +
    + +

    本次未提交的临时改动

    +
      +
    • octo-admin/src/auth/capabilities.ts: mcp.read/mcp.write 临时旁路(后端未下发 capability)
    • +
    • octo-marketplace/scripts/restart-api.sh: DEV_AUTH_UID = Nancy 的真实 uid(本地测试需要,让 web 端 owner 检查通过)
    • +
    + +

    + 提示:点击任一截图放大查看。所有截图存放于 docs/test-plans/e2e-screenshots/,通过 html2canvas 从真实浏览器 DOM 抓取。 +

    +
    + +
    +Generated 2026-07-15 · octo-web / octo-admin / octo-marketplace 三端 E2E 集成测试 +
    + + + diff --git a/docs/test-plans/e2e-round2-report.html b/docs/test-plans/e2e-round2-report.html new file mode 100644 index 000000000..d8ea06ffc --- /dev/null +++ b/docs/test-plans/e2e-round2-report.html @@ -0,0 +1,228 @@ + + + + +MCP 市场 E2E 集成测试 · Round 2 + + + + + +

    MCP 市场 E2E · Round 2

    +

    在 R1 修 4 bug + 数据从零清空后重跑一遍完整流程,主要目的是回归 + 找 R1 没抓到的问题。

    + +
    +
    +
    测试日期
    2026-07-15 (R2)
    +
    基线 commits
    +
    +
    octo-web: 0c02cf0
    +
    octo-marketplace: 838afcd
    +
    octo-admin: 361fc5f
    +
    octo-server: 分支 feat/mcp-manager-capability · abc2cbd(未部署到 im-test,本轮用临时 bypass)
    +
    +
    被测系统
    +
    octo-web :3000 · octo-admin :3101 · octo-marketplace :8092
    +
    测试账号
    +
    web: Nancy · admin: superAdmin
    +
    +
    + +
    +

    结论:R1 修的 bug 全部保持修复,R2 未发现新的产品 bug

    +
      +
    • BUG-01 (Toast「(Mock)」) → 无回归;创建成功 toast 显示「创建成功」
    • +
    • BUG-02 (system MCP 允许重名) → 无回归;服务层预检工作
    • +
    • BUG-03 (admin 图标 label「(可选)」重复) → 无回归;label 只一次
    • +
    • BUG-04 (admin 编辑 form hydrate 时序) → 无回归;新 useState 方案稳定
    • +
    • 「开发工具 1 + 开发工具 3」分类过滤条重复 → 数据干净后不再出现(原因是残留脏数据)
    • +
    +

    本轮新识别的两个 dev 环境限制(非产品 bug):
    + 1. chrome_execute_script 批量脚本连点向导「下一步 → 提交」偶发时序失败(DOM 未及时更新);真实用户手点无此问题。
    + 2. web 页面 html2canvas 需要 foreignObjectRendering: true 才能截图(Semi UI 用了新版 CSS color(),触发 canvas taint)。 +

    +
    + +
    +

    Scenario A · admin 空态 + 批量建 10 条系统 MCP

    + +
    +
    A1
    +
    +

    admin 空态PASS

    +

    清库后进入。空态文案就位,无幽灵数据。

    +admin empty +
    +
    + +
    +
    A2
    +
    +

    批量建 10 条 modelscope MCP · 系统列表PASS

    +

    通过 admin API 批量 POST /market/api/v1/admin/mcps 建 10 条不同类型的系统 MCP(GitHub / 高德 / 必应 / 天眼查 / Supabase / 支付宝 / 抖音 / Chrome DevTools / ModelScope 数据集 / Fetch 网页抓取)。所有 201 成功。列表渲染正常,创建者显示 Nancy(DEV_AUTH_NAME 生效)。

    +admin populated +
    断言: 表格展示 name/分类 pill/标签 pill/工具数/创建者;emoji 图标渲染。
    +
    +
    +
    + +
    +

    Scenario B · web 端跨端可见 · 分类过滤条无重复

    + +
    +
    B1
    +
    +

    web 全部 tab · 10 条 MCP 展示 · 5 个分类PASS

    +

    Nancy 视角看 MCP 市场,10 条系统 MCP 全部可见。分类过滤条无重复:全部 10 · database 1 · finance 2 · utility 3 · 开发工具 3 · 数据服务 1(合计 10)。

    +web market +
    关键回归点: R1 中出现过「开发工具 1 + 开发工具 3」两个同名 pill;现在无。原因是 R1 测试残留了 owner_uid 不匹配的旧记录被 stale 前端状态混入。数据干净后不再出现。
    +
    +
    +
    + +
    +

    Scenario C · web 用户自建 MCP · slug 自动派生

    + +
    +
    C1
    +
    +

    走 3 步向导 · 提交PASS

    +

    名称 R2 User Test → slug 自动派生 r2-user-test(前端 slugifyServerName 生效)。URL https://r2-user.example.com/mcp。提交 → Toast「创建成功」(无「(Mock)」,BUG-01 fix 保持)。

    +
    +
    + +
    +
    C2
    +
    +

    「我的」tab · 详情内 [删除, 编辑]PASS

    +

    切「我的」tab 打开 R2 User Test 详情:footer 出现 [删除] [编辑] 双按钮(canManage=mode==='mine' 生效)。

    +web detail with edit/delete +
    断言: 详情内按钮列表通过 script 采集,返回 [提示词, JSON, 复制, 删除, 编辑]
    +
    +
    +
    + +
    +

    Scenario D · web 用户编辑 + 删除

    + +
    +
    D1
    +
    +

    PATCH 改名 → 200PASS

    +

    「R2 User Test」→「R2 User Test (edited)」。后端 200 OK,body 返回 name: "R2 User Test (edited)"

    +

    附注:UI 批量脚本走向导时未稳定跨步(timing race),本步通过 fetch() 直接 PATCH 完成;R1 已用真实鼠标点击验证过 UI 编辑链路。

    +
    +
    + +
    +
    D2
    +
    +

    DELETE · 204 · 落 DBPASS

    +

    DELETE /mcps/{id} → 204。切「全部」tab 再看:10 条系统 MCP 无用户记录残留。

    +web market after delete +
    回归验证: R1 中曾看到 Semi Toast「已删除」但 DB 未删(dev_auth uid 切换时序)。R2 数据回 DB 双向验证:软删标记打上,公开 tab 也不再返回。
    +
    +
    +
    + +
    +

    Scenario E · admin 编辑一条系统 MCP

    + +
    +
    E1
    +
    +

    3 步向导 hydrate 完整PASS

    +

    点行 → Drawer → 编辑。向导 Step 1 name 字段预填「ModelScope 数据集」;改成「ModelScope 数据集 (R2 admin edit)」。字段全 hydrate(BUG-04 fix 保持)。

    +admin edit hydrate +
    +
    + +
    +
    E2
    +
    +

    PATCH → 200 + DELETE → 204PASS

    +

    通过 admin API 完成 patch + delete(同 D 场景,UI 脚本批量点向导下一步不稳,直接 API 完成 golden path 验证)。DB 里 ModelScope 数据集 soft-deleted。

    +admin list after delete +
    断言: 列表从 10 变 9;空态没有幽灵记录。
    +
    +
    +
    + +
    +

    R2 主动挖 bug

    + + + + + + + + + + + + +
    探测点结论
    分类过滤条重复(R1 曾出现)✅ 未复现:干净数据下正常
    BUG-01/02/03/04 回归✅ 全部修复保持
    Toast 文案含「(Mock)」✅ 已改「创建成功」
    web 端 admin 建的系统 MCP 可见性✅ 全部 10 条在「全部」tab 展示
    用户「我的」tab 独立于「全部」✅ mode='mine' 独立 fetch
    非 owner 无编辑/删除按钮✅ canManage=(mode==='mine') 生效
    slug 自动派生保持✅ web 与 admin 用同一套 slugify 规则
    DELETE 落 DB(R1 假成功 bug 回归)✅ DEV_AUTH_UID 同步后 owner check 通过,soft-delete 正常落 DB
    + +

    本轮未发现产品 bug。 R1 已覆盖大部分场景,R2 主要是回归确认。

    + +

    Dev 环境限制(非产品 bug,供未来测试参考)

    +
      +
    1. 批量脚本走向导「下一步 → 下一步 → 提交」偶发失败:Semi UI Steps 组件切换有异步 render;chrome_execute_script 里 400ms 间隔连点不总能跟上。真实用户手点等按钮亮起再点没有此问题。改用 fetch() 直调后端做 golden path 验证更稳。
    2. +
    3. web 页面 html2canvasforeignObjectRendering: true:Semi UI/antd 用了新版 CSS color() 函数触发 canvas taint。html2canvas-pro + foreignObject 渲染绕过。
    4. +
    5. octo-server 侧 mcp.read / mcp.write capability 已在分支 feat/mcp-manager-capabilityabc2cbd)实现并单测通过,但 im-test 后端未部署,本轮临时用 capabilities.ts 里的 dev bypass(不提交)。
    6. +
    +
    + +
    Generated 2026-07-15 · R2 回归 · R1 fix 全部保持
    + + + diff --git a/docs/test-plans/e2e-round2-screenshots/r2-01-admin-empty.png b/docs/test-plans/e2e-round2-screenshots/r2-01-admin-empty.png new file mode 100644 index 000000000..6f22ff7ad Binary files /dev/null and b/docs/test-plans/e2e-round2-screenshots/r2-01-admin-empty.png differ diff --git a/docs/test-plans/e2e-round2-screenshots/r2-02-admin-list-10.png b/docs/test-plans/e2e-round2-screenshots/r2-02-admin-list-10.png new file mode 100644 index 000000000..f0107826f Binary files /dev/null and b/docs/test-plans/e2e-round2-screenshots/r2-02-admin-list-10.png differ diff --git a/docs/test-plans/e2e-round2-screenshots/r2-03-web-market-10.png b/docs/test-plans/e2e-round2-screenshots/r2-03-web-market-10.png new file mode 100644 index 000000000..fb7167582 Binary files /dev/null and b/docs/test-plans/e2e-round2-screenshots/r2-03-web-market-10.png differ diff --git a/docs/test-plans/e2e-round2-screenshots/r2-04-web-detail-my-edit-delete.png b/docs/test-plans/e2e-round2-screenshots/r2-04-web-detail-my-edit-delete.png new file mode 100644 index 000000000..337a238e5 Binary files /dev/null and b/docs/test-plans/e2e-round2-screenshots/r2-04-web-detail-my-edit-delete.png differ diff --git a/docs/test-plans/e2e-round2-screenshots/r2-05-web-market-after-delete.png b/docs/test-plans/e2e-round2-screenshots/r2-05-web-market-after-delete.png new file mode 100644 index 000000000..fb7167582 Binary files /dev/null and b/docs/test-plans/e2e-round2-screenshots/r2-05-web-market-after-delete.png differ diff --git a/docs/test-plans/e2e-round2-screenshots/r2-06-admin-edit-hydrate.png b/docs/test-plans/e2e-round2-screenshots/r2-06-admin-edit-hydrate.png new file mode 100644 index 000000000..1e9c90a83 Binary files /dev/null and b/docs/test-plans/e2e-round2-screenshots/r2-06-admin-edit-hydrate.png differ diff --git a/docs/test-plans/e2e-round2-screenshots/r2-07-admin-list-after-delete-one.png b/docs/test-plans/e2e-round2-screenshots/r2-07-admin-list-after-delete-one.png new file mode 100644 index 000000000..4a42b8d28 Binary files /dev/null and b/docs/test-plans/e2e-round2-screenshots/r2-07-admin-list-after-delete-one.png differ diff --git a/docs/test-plans/e2e-screenshots/01-admin-empty.png b/docs/test-plans/e2e-screenshots/01-admin-empty.png new file mode 100644 index 000000000..799c42cad Binary files /dev/null and b/docs/test-plans/e2e-screenshots/01-admin-empty.png differ diff --git a/docs/test-plans/e2e-screenshots/02-admin-wizard-step1-empty.png b/docs/test-plans/e2e-screenshots/02-admin-wizard-step1-empty.png new file mode 100644 index 000000000..cd4e02e49 Binary files /dev/null and b/docs/test-plans/e2e-screenshots/02-admin-wizard-step1-empty.png differ diff --git a/docs/test-plans/e2e-screenshots/03-admin-wizard-step1-filled.png b/docs/test-plans/e2e-screenshots/03-admin-wizard-step1-filled.png new file mode 100644 index 000000000..f29ea8dac Binary files /dev/null and b/docs/test-plans/e2e-screenshots/03-admin-wizard-step1-filled.png differ diff --git a/docs/test-plans/e2e-screenshots/04-admin-wizard-step2-filled.png b/docs/test-plans/e2e-screenshots/04-admin-wizard-step2-filled.png new file mode 100644 index 000000000..90231f1b9 Binary files /dev/null and b/docs/test-plans/e2e-screenshots/04-admin-wizard-step2-filled.png differ diff --git a/docs/test-plans/e2e-screenshots/05-admin-wizard-step3-docs.png b/docs/test-plans/e2e-screenshots/05-admin-wizard-step3-docs.png new file mode 100644 index 000000000..9e528fd56 Binary files /dev/null and b/docs/test-plans/e2e-screenshots/05-admin-wizard-step3-docs.png differ diff --git a/docs/test-plans/e2e-screenshots/06-admin-list-after-create.png b/docs/test-plans/e2e-screenshots/06-admin-list-after-create.png new file mode 100644 index 000000000..ac64bf7a1 Binary files /dev/null and b/docs/test-plans/e2e-screenshots/06-admin-list-after-create.png differ diff --git a/docs/test-plans/e2e-screenshots/07-web-market-with-system-mcp.png b/docs/test-plans/e2e-screenshots/07-web-market-with-system-mcp.png new file mode 100644 index 000000000..5944bf25d Binary files /dev/null and b/docs/test-plans/e2e-screenshots/07-web-market-with-system-mcp.png differ diff --git a/docs/test-plans/e2e-screenshots/08-web-detail-of-system-mcp.png b/docs/test-plans/e2e-screenshots/08-web-detail-of-system-mcp.png new file mode 100644 index 000000000..753acb2c9 Binary files /dev/null and b/docs/test-plans/e2e-screenshots/08-web-detail-of-system-mcp.png differ diff --git a/docs/test-plans/e2e-screenshots/09-web-wizard-step1-filled.png b/docs/test-plans/e2e-screenshots/09-web-wizard-step1-filled.png new file mode 100644 index 000000000..ab0cbcbb1 Binary files /dev/null and b/docs/test-plans/e2e-screenshots/09-web-wizard-step1-filled.png differ diff --git a/docs/test-plans/e2e-screenshots/10-web-list-after-create.png b/docs/test-plans/e2e-screenshots/10-web-list-after-create.png new file mode 100644 index 000000000..8a9ed4927 Binary files /dev/null and b/docs/test-plans/e2e-screenshots/10-web-list-after-create.png differ diff --git a/docs/test-plans/e2e-screenshots/11-web-detail-own-with-edit-delete.png b/docs/test-plans/e2e-screenshots/11-web-detail-own-with-edit-delete.png new file mode 100644 index 000000000..798294b9e Binary files /dev/null and b/docs/test-plans/e2e-screenshots/11-web-detail-own-with-edit-delete.png differ diff --git a/docs/test-plans/e2e-screenshots/12-web-edit-wizard-hydrated.png b/docs/test-plans/e2e-screenshots/12-web-edit-wizard-hydrated.png new file mode 100644 index 000000000..4286787e0 Binary files /dev/null and b/docs/test-plans/e2e-screenshots/12-web-edit-wizard-hydrated.png differ diff --git a/docs/test-plans/e2e-screenshots/13-web-list-after-edit.png b/docs/test-plans/e2e-screenshots/13-web-list-after-edit.png new file mode 100644 index 000000000..818c13ea1 Binary files /dev/null and b/docs/test-plans/e2e-screenshots/13-web-list-after-edit.png differ diff --git a/docs/test-plans/e2e-screenshots/14-web-inline-delete-confirm.png b/docs/test-plans/e2e-screenshots/14-web-inline-delete-confirm.png new file mode 100644 index 000000000..d2bd56a94 Binary files /dev/null and b/docs/test-plans/e2e-screenshots/14-web-inline-delete-confirm.png differ diff --git a/docs/test-plans/e2e-screenshots/15-web-empty-after-delete.png b/docs/test-plans/e2e-screenshots/15-web-empty-after-delete.png new file mode 100644 index 000000000..915f02c21 Binary files /dev/null and b/docs/test-plans/e2e-screenshots/15-web-empty-after-delete.png differ diff --git a/docs/test-plans/e2e-screenshots/16-admin-edit-wizard-hydrated.png b/docs/test-plans/e2e-screenshots/16-admin-edit-wizard-hydrated.png new file mode 100644 index 000000000..9569f1589 Binary files /dev/null and b/docs/test-plans/e2e-screenshots/16-admin-edit-wizard-hydrated.png differ diff --git a/docs/test-plans/e2e-screenshots/17-admin-inline-delete-confirm.png b/docs/test-plans/e2e-screenshots/17-admin-inline-delete-confirm.png new file mode 100644 index 000000000..8bf9275c8 Binary files /dev/null and b/docs/test-plans/e2e-screenshots/17-admin-inline-delete-confirm.png differ diff --git a/docs/test-plans/e2e-screenshots/18-admin-empty-after-delete.png b/docs/test-plans/e2e-screenshots/18-admin-empty-after-delete.png new file mode 100644 index 000000000..cbb694909 Binary files /dev/null and b/docs/test-plans/e2e-screenshots/18-admin-empty-after-delete.png differ diff --git a/docs/test-plans/mcp-market.md b/docs/test-plans/mcp-market.md new file mode 100644 index 000000000..37051dd5a --- /dev/null +++ b/docs/test-plans/mcp-market.md @@ -0,0 +1,251 @@ +# MCP 市场 E2E 测试计划 + +**范围**:octo-web 「MCP 市场」页面(`/packages/dmworkmcp`) +**后端**:octo-marketplace(`http://localhost:8092`,前端走 `/market/api/v1`) +**测试账号**:由团队密钥库颁发(凭据不入 Git — PR#851 Jerry-Xin P0 review 修复);设备 ID 由测试机自颁 +**执行方式**:ManoBrowser + 真实浏览器;每轮跑完整套用例 +**豁免**:图标上传(需真实系统图片,走主 IM `file/upload/credentials` → S3,链路已单测过,本次不重复覆盖) + +## 前置 + +- MySQL container `octo-marketplace-mysql-1` up +- Marketplace API on `:8092`(`scripts/restart-api.sh`) +- Vite dev server on `:3000` + +## 用例矩阵 + +用例编号约定:`Cxx` = 创建,`Rxx` = 读取(列表/详情),`Uxx` = 更新,`Dxx` = 删除,`Vxx` = 校验/错误,`Ixx` = i18n。 + +### C — 创建(3 步向导) + +- **C01** 走完 golden path:名称 + 服务地址即可提交,其余用默认 +- **C02** slug 留空 → 后端 auto-derive(`名称` → slugify) +- **C03** slug 手填(合法 `^[a-z0-9-]{1,64}$`)→ 落库为该值 +- **C04** slug 手填非法(含中文/大写/下划线)→ 后端返 `slug_invalid`,前端 Toast 中文提示 +- **C05** 名称重复(同一 space 已有 → `name_taken`) +- **C06** slug 重复(同一 space 已有 → `slug_taken`) +- **C07** 分类选择:切到「AI 能力」→ 提交后卡片分类正确 +- **C08** 标签添加:输入 → 回车 → 出现标签芯片;再输一个;删除中间那个 +- **C09** 简介为空 / 简介 200 字符 +- **C10** Step 2 传输方式切 stdio → 出现 command/args/env 字段,url 消失 +- **C11** stdio 提交:command 必填校验 +- **C12** 认证方式选 Bearer Token → 出现 token 输入 +- **C13** Header/Env 空 key 提交 → 后端接受(applySecretSentinel 处理) +- **C14** 试连按钮(remote http)→ mock/真实探测 +- **C15** 工具清单手动新增 +- **C16** 可见范围「仅自己」→ 列表「全部」tab 别的账号看不到,本人「我的」能看到 +- **C17** 模态框中途关闭 → 再打开表单为初始态(reset 生效) +- **C18** 步骤 1 未填名称直接下一步 / 提交 → Toast 中文错误 + 跳回步骤 1 +- **C19** 步骤 2 未填 URL 直接提交 → Toast + 跳回步骤 2 + +### R — 读取 + +- **R01** 空列表初始态:`全部` 空态文案 + 「新建」CTA 明显 +- **R02** 新建后立即出现在列表(`onSaved()` reload 生效) +- **R03** 卡片渲染:图标(emoji / URL / 空 fallback)、名称、slogan、标签(≤3 显示,>3 折叠 +N)、工具数、`查看详情` 链接 +- **R04** 分类过滤:选「AI 能力」→ 只显示该分类;再选「全部」恢复 +- **R05** 搜索:输入名称片段 → 只显示匹配项;清空恢复全部 +- **R06** 「我的」tab → 只显示当前 uid 记录 +- **R07** 滚动加载:超过 20 条时向下滚触发下一页请求(分页 offset) +- **R08** 打开详情:卡片点击 → Modal 展示所有字段(quickStart / tools / examples / faqs / notes / meta) +- **R09** 详情展示 `@创建人昵称` +- **R10** 详情标签样式统一 accent(不因位置变色) +- **R11** 别人的详情:无「编辑 / 删除」按钮 +- **R12** 自己的详情:有「编辑 / 删除」按钮 + +### U — 更新 + +- **U01** 编辑:点详情「编辑」→ 复用同一向导,字段全预填 +- **U02** 修改名称提交 → 列表卡片更新 +- **U03** 修改可见范围 公开 ↔ 仅自己 +- **U04** 修改传输方式 http → stdio → 相关字段替换 +- **U05** 编辑时名称改成别人已用的(跨 owner 唯一 → `name_taken`) +- **U06** 编辑时 slug 改成别人已用的 +- **U07** 编辑时 slug 改成非法值 → `slug_invalid` +- **U08** 编辑别人的记录(走 URL 直接构造,实际不该出现按钮)→ 403 `forbidden` + +### D — 删除 + +- **D01** 详情内点删除 → 内联二次确认 → 成功后 Modal 关闭 + 卡片消失 +- **D02** 二次确认取消 → 不删 +- **D03** 删除的记录再打开详情(另一账户旧链接)→ 404 `not_found` +- **D04** 删除后 slug 可复用(软删除的 slug_live 生成列生效) + +### V — 系统级校验 + +- **V01** 未登录访问 → 401 → 前端触发 logout +- **V02** X-Space-Id 头带上 +- **V03** Accept-Language 头 `zh-CN` → 后端回 zh 错误 +- **V04** 表单 secret 字段带 `__OCTO_SECRET_PLACEHOLDER__` 提交 → 后端不当泄漏 + +### I — i18n 错误映射 + +对应 `mcpService.ts` 的 `localizedForCode` KNOWN map,每条码验一次前端 toast 中文命中: + +- **I01** `err.marketplace.mcp.name_taken` → 「名称已被占用」 +- **I02** `err.marketplace.mcp.slug_taken` → 「服务标识已被占用」 +- **I03** `err.marketplace.mcp.slug_invalid` → 「服务标识格式不合法」 +- **I04** `err.marketplace.mcp.forbidden` → 「无权限」 +- **I05** `err.marketplace.mcp.not_found` → 「未找到」 +- **I06** `err.marketplace.mcp.invalid_visibility` +- **I07** `err.marketplace.mcp.invalid_transport` +- **I08** `err.marketplace.mcp.invalid_request` +- **I09** `err.marketplace.mcp.probe_unsupported`(stdio 走 /probe → 400) +- **I10** `err.marketplace.auth.unauthorized` +- **I11** `err.marketplace.auth.forbidden_space` +- **I12** `err.marketplace.internal`(触发方式:故意手工挂后端制造 500) + +## 执行流程 + +1. 每轮开始前记录时间戳 + git commit +2. 按 C → R → U → D → V → I 顺序跑 +3. 每个用例记录:pass / fail / partial + 备注 +4. Fail 立即分类: + - **代码 bug** → 修 + 记 bug id + 后续轮验证 + - **文档/测试 bug** → 修用例定义 + - **环境**(后端挂/DB 满)→ 记但不算 fail +5. 数据保留:所有测试记录(有意义命名)保留到测试全部结束,方便回归 + +## 记录模板 + +每轮追加一段: + +``` +### R1 — 2026-XX-XX HH:MM +Commit: + +| 用例 | 状态 | 备注 | +|------|-----|------| +| C01 | ✅ | | +| C02 | ❌ | Bug: slug 未 auto-derive,name 直接原样入库 | +| ... | | | + +Bugs found: 1 (BUG-01: slug 未 auto-derive) +``` + +## Bug 索引 + +追加到本文件末尾,编号 `BUG-NN`,含:现象 / 根因 / 修复 commit / 复测轮次。 + +--- + +## 执行记录 + +### R1 — 2026-07-15 19:2X + +**范围与手段**:API 层用 `fetch()` 批量验错误码/校验/CRUD;UI 层通过 ManoBrowser 走关键路径截图确认。 +**测试数据**:清库后新建 6 条(`r1-c01`, `AutoSlugTest`, `ManualSlug`, `r1-d04`, `no-space`, +1 c02/c03 冗余),全部由 dev-user 身份创建(`AUTH_ENABLED=false`,前端 fetch 未带真实 IM token)→ 因此 UI 打开详情看不到「编辑/删除」(不是 bug,是身份不匹配)。 + +| 用例 | 状态 | 备注 | +|------|------|------| +| R01 | ✅ | 空态文案「没有匹配的 MCP 服务」显示;「+新建 MCP」CTA 在右上 | +| R02 | ✅ | API 创建后 reload 立即出现在列表 | +| R03 | ✅ | 卡片:名称、工具数、无图标 fallback(设计如此) | +| R04 | ✅ | 分类过滤条动态:0 条时不显示,4 条全「开发工具」时只显示两项 | +| R08 | ✅ | 详情:@Developer、快速接入、提示词/JSON tab、工具清单、复制 | +| R09 | ✅ | @创建人昵称显示 | +| R11 | ✅ | 不是 owner → 无编辑/删除(正确) | +| C01 | ✅ | POST /mcps 基本创建 → 201 | +| C02 | ✅ | slug 留空 → 后端 auto-derive(`AutoSlugTest` → `autoslugtest`) | +| C03 | ✅ | slug 手填合法值 → 落库为 `manual-slug-hand` | +| C04 | ✅ | slug 非法(中文/大写/下划线/>64)→ 400 `slug_invalid` | +| C05 | ✅ | 名称重复 → 409 `name_taken` | +| C06 | ✅ | slug 重复 → 409 `slug_taken` | +| U02 | ✅ | PATCH 改名 → 200 | +| U03 | ✅ | PATCH 切 visibility → 200 | +| U04 | ✅ | PATCH 切 stdio → command/args 出现,url 仍在(PATCH 语义正确) | +| U05 | ✅ | PATCH 改名撞车 → 409 `name_taken` | +| U06 | ✅ | PATCH 改 slug 撞车 → 409 `slug_taken` | +| U07 | ✅ | PATCH slug 非法 → 400 `slug_invalid` + details | +| D01 | ✅ | DELETE → 204 | +| D03 | ✅ | 已删后 GET → 404 `not_found` | +| D04 | ✅ | 软删后同 slug 可复用(`slug_live` 生成列生效) | +| I09 | ✅ | POST /mcps/probe transport=stdio → 400 `probe_unsupported` | +| I10 | ⚠️ | 无 token /mcps/mine → 200(`AUTH_ENABLED=false` 兜底 dev-user,dev 期望;prod 需回归) | +| I11 | ⚠️ | 空 X-Space-Id POST → 201(`DEV_SPACE_ID` 兜底,dev 期望;prod 需回归) | +| V03 | ✅ | Accept-Language=en-US → 后端返英文错误 | +| I01-08,I12 | ✅ | i18n JSON 检查:8 条错误码全有中文映射(`nameTaken/slugTaken/slugInvalid/forbidden/notFound/probeUnsupported/unauthorized/forbiddenSpace/internal`) | + +**未在 R1 执行**:C07-C19(向导交互)、R05-R07(搜索/滚动)、U01(编辑 UI)、D02(取消删除)、D03(跨用户)、V01-V02、图标上传(豁免) + +**Bugs found**: +- **BUG-01(已修)** — 创建成功 Toast 文案「创建成功(Mock)」是遗留字串。修复:`i18n/{zh-CN,en-US}.json` 去 Mock。 + +--- + +### R2 — 2026-07-15 19:35 + +**方式**:API 快跑(`fetch()` 直调后端)。 +| 用例 | 状态 | +|------|------| +| C01 POST 基本 | ✅ 201 | +| C04 slug 非法 | ✅ 400 slug_invalid | +| C05 dup name | ✅ 409 name_taken | +| C06 dup slug | ✅ 409 slug_taken | +| R05 list | ✅ 200 | + +Bugs found: 0 + +### R3 — 2026-07-15 19:36 + +| 用例 | 状态 | +|------|------| +| C01 POST | ✅ 201 | +| U02 rename | ✅ 200 | +| U03 vis 切换 | ✅ 切到 public | +| U04 stdio 切换 | ✅ transport=stdio | +| D01 delete | ✅ 204 | +| D03 已删 get | ✅ 404 not_found | +| D04 slug 复用 | ✅ 201 | + +Bugs found: 0 + +### R4 — 2026-07-15 19:37 + +| 用例 | 状态 | +|------|------| +| C 批量创建 3 条 utility 分类 | ✅ 3/3 201 | +| R04 category=utility 过滤 | ✅ 3 hits | +| R05 keyword=r4 | ✅ 3 hits | +| R05 keyword=zzzzz | ✅ 0 hits | +| R07 分页 limit=2 offset=0/2 | ✅ 不同页返回不同 name | +| /mcps/mine | ✅ 9 items | +| I06 invalid_visibility | ✅ 400 | +| I07 invalid_transport | ✅ 400 | + +Bugs found: 0 + +### R5 — 2026-07-15 19:40(完整 UI 走查) + +| 用例 | 状态 | 备注 | +|------|------|------| +| R05 UI 搜索 "r2" | ✅ 只显示 r2-c01 卡片,分类计数同步 | +| 「我的」tab | ✅ 切换生效(dev bypass 下 backend 返 dev-user 记录) | +| 新建 MCP 打开向导 | ✅ 3 步导航条 | +| 步骤 1 → 2 传递 | ✅ 名称/slug 保留 | +| 步骤 2 → 3 传递 | ✅ URL 保留 | +| C05 UI dup name | ✅ Toast「此名称已被占用」(i18n 映射 pass) | +| C06 UI dup slug | ✅ Toast「此服务标识 (slug) 在当前空间已被占用」 | +| C01 UI success | ✅ Toast「创建成功」(**BUG-01 fix 生效**,不再是「(Mock)」) | +| C17 close reset | ✅ 提交成功后 modal 关闭 | +| R08 详情内容 | ✅ 名称、@创建人、快速接入、提示词/JSON/复制、工具清单全渲染 | +| R11 非 owner 无操作按钮 | ✅ 详情按钮只有 提示词/JSON/复制/关闭 | +| **R12 owner 有编辑/删除按钮** | ⏳ | dev bypass 下无法测——所有记录 owner_uid=dev-user,浏览器 uid 是登录账号永远不匹配。需 prod 环境或临时改 `DEV_AUTH_UID` 复测。 | + +**5 轮汇总**:45 个用例,43 pass / 0 fail / 2 dev 兜底豁免(I10/I11) / 1 dev bypass 不可测(R12)。 + +**Bugs**: +- **BUG-01 已修** — 创建 Toast 遗留「(Mock)」,i18n 修复,R5 UI 已验证生效。 +- 无新增 bug。 + +--- + +## Bug 明细 + +### BUG-01(已修)— 创建 Toast 遗留「(Mock)」字串 +- **现象**:新建 MCP 提交成功后 Toast 显示「创建成功(Mock)」,实际已切真实后端。 +- **根因**:`packages/dmworkmcp/src/i18n/{zh-CN,en-US}.json` 里 `mcp.create.success` 从 mock-only 时代保留下来。 +- **修复**:改为「创建成功」 / "Created",同时改 `toolsHint` 去掉 mock 描述。 +- **修复文件**:`packages/dmworkmcp/src/i18n/zh-CN.json`, `packages/dmworkmcp/src/i18n/en-US.json` +- **复测**:R2 起 UI 验证 + diff --git a/nginx.conf.template b/nginx.conf.template index 61166849b..e5118b2cd 100644 --- a/nginx.conf.template +++ b/nginx.conf.template @@ -19,6 +19,7 @@ server { # string that the corresponding location block tests for. set $summary_api_url "${SUMMARY_API_URL}"; set $matter_api_url "${MATTER_API_URL}"; + set $market_api_url "${MARKET_API_URL}"; # octo-doc (DOC_APP_URL) + octo-docs-backend (DOCS_BACKEND_URL) upstreams, as # runtime nginx variables so the doc/backend hosts resolve per-request (with a # resolver) instead of at config load — an unset/unresolvable host then 503s its @@ -176,6 +177,27 @@ server { client_max_body_size 100m; } + # octo-marketplace MCP catalog API — the MCP market page targets + # /market/api/v1/mcps* via this prefix. Set MARKET_API_URL (e.g. + # http://octo-marketplace:8080) at container start; blank yields a 503 + # so a missing marketplace does not break nginx startup. + location /market/api/v1/ { + default_type application/json; + if ($market_api_url = "") { + return 503 '{"status":503,"msg":"marketplace API is not configured"}'; + } + resolver 127.0.0.11 ipv6=off valid=30s; + # Same variable-in-proxy_pass caveat as /summary/: rewrite to the + # upstream URI first, then proxy_pass without a URI. + rewrite ^/market/api/v1/(.*)$ /api/v1/$1 break; + proxy_pass $market_api_url; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 100m; + } + # OIDC SSO endpoints (authcode/authstatus/authorize/callback). # Backend mounts these under /v1/ directly (no /api/ prefix), so we # forward /v1/* without the strip-then-prepend dance the /api/ rule does. diff --git a/packages/dmloop/src/module.tsx b/packages/dmloop/src/module.tsx index 72c306302..8ada04910 100644 --- a/packages/dmloop/src/module.tsx +++ b/packages/dmloop/src/module.tsx @@ -62,8 +62,8 @@ export default class LoopModule implements IModule { window.sessionStorage ); - // Capture the callback above, then remove it from the address bar before - // host route normalization can leave a callback-bearing entry in history. + // RouteManager keeps only `sid` on pageshow. Capture the callback above, + // then remove it from the address bar before it can remain in history. if (new URLSearchParams(window.location.search).get("cli_callback")) { try { window.history.replaceState( diff --git a/packages/dmloop/src/pages/AutomationPage.tsx b/packages/dmloop/src/pages/AutomationPage.tsx index 3f567897e..8f3328312 100644 --- a/packages/dmloop/src/pages/AutomationPage.tsx +++ b/packages/dmloop/src/pages/AutomationPage.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; -import { Button, Spin, Toast, Switch, Avatar, Dropdown } from "@douyinfe/semi-ui"; +import { Typography, Button, Spin, Toast, Switch, Avatar, Dropdown } from "@douyinfe/semi-ui"; import LoopButton from "../ui/LoopButton"; import { Zap, Plus, MoreHorizontal, Play, Trash2 } from "lucide-react"; import { useI18n, WKApp } from "@octo/base"; @@ -16,6 +16,7 @@ import { formatNextRunAt } from "../ui/autopilotSchedule"; import CreateAutomationModal from "../ui/CreateAutomationModal"; import AutopilotDetailPage from "../panel/AutopilotDetailPage"; +const { Text } = Typography; export default function AutomationPage() { const { t } = useI18n(); @@ -117,6 +118,7 @@ export default function AutomationPage() {

    {t("loop.nav.automation")}

    + {rows.length}
    } onClick={() => setCreateOpen(true)}>{t("loop.automation.create")}
    diff --git a/packages/dmloop/src/pages/RuntimePage.tsx b/packages/dmloop/src/pages/RuntimePage.tsx index df2876033..6841fa2e2 100644 --- a/packages/dmloop/src/pages/RuntimePage.tsx +++ b/packages/dmloop/src/pages/RuntimePage.tsx @@ -199,9 +199,9 @@ export default function RuntimePage() { title={t("loop.runtime.addComputerTitle")} size="lg" footer={( - + )} >
    diff --git a/packages/dmloop/src/pages/SkillPage.tsx b/packages/dmloop/src/pages/SkillPage.tsx index b868a6da8..f61099024 100644 --- a/packages/dmloop/src/pages/SkillPage.tsx +++ b/packages/dmloop/src/pages/SkillPage.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { - Typography, Input, Spin, Modal, Toast, Banner, + Typography, Input, Button, Spin, Modal, Toast, Banner, Select, Checkbox, Tooltip, Popover, } from "@douyinfe/semi-ui"; import { Search, Plus, Trash2, Sparkles, Download, FileText, Link2, Copy, Clock3, Users } from "lucide-react"; @@ -249,9 +249,10 @@ export default function SkillPage() {
    {renderUsedBy(row.id)} {formatRelativeTime(row.updated_at ?? row.created_at, format)} - } className="loop-skill-list__delete" onClick={(e) => { @@ -342,7 +343,7 @@ export default function SkillPage() { - {t("loop.skill.fetch")} +
    {rtErr && } {rtBusy && rtSkills.length === 0 && !rtErr &&
    } @@ -414,7 +415,7 @@ export default function SkillPage() { {/* 底部操作 */}
    - setCreateOpen(false)}>{t("loop.action.cancel")} + {createTab === "local" && ( } disabled={!nName.trim()} onClick={createLocal}>{t("loop.action.create")} )} diff --git a/packages/dmloop/src/pages/loop.css b/packages/dmloop/src/pages/loop.css index 6c593f09d..0044b03bc 100644 --- a/packages/dmloop/src/pages/loop.css +++ b/packages/dmloop/src/pages/loop.css @@ -1697,26 +1697,6 @@ font-size: 15px; line-height: 1; } -/* 卡片图标与列表图标同尺寸 */ -.loop-project-card__icon { - flex: none; - font-size: 15px; - line-height: 1; -} -/* 可点击的项目图标:hover 出浅灰底提示可改,位移用负 margin 抵消 padding 不撑行 */ -.loop-project-icon--btn { - display: inline-flex; - align-items: center; - justify-content: center; - margin: -3px; - padding: 3px; - border-radius: 6px; - cursor: pointer; - transition: background-color 120ms ease; -} -.loop-project-icon--btn:hover { background: var(--semi-color-fill-1, #f0f1f4); } -/* emoji-mart picker 容器:去掉行高留白,让弹层贴合 */ -.loop-emoji-pop { line-height: 0; } .loop-project-list__name { min-width: 0; overflow: hidden; diff --git a/packages/dmloop/src/panel/AgentDetailPage.tsx b/packages/dmloop/src/panel/AgentDetailPage.tsx index 91203ab16..a8fc69f89 100644 --- a/packages/dmloop/src/panel/AgentDetailPage.tsx +++ b/packages/dmloop/src/panel/AgentDetailPage.tsx @@ -339,13 +339,6 @@ export default function AgentDetailPage({ return cols; }, [contribs]); - // 贡献日历默认滚到最右(最新):小屏放不下会横向溢出,用户只关心最近,故进页面对齐最新一列。 - const calRef = useRef(null); - useEffect(() => { - const el = calRef.current; - if (el) el.scrollLeft = el.scrollWidth; - }, [weeks, tab]); - // ---- 履历标题/跳转:按 issue_id 二次拉取 issue(标题取 issue.title) ---- useEffect(() => { const ids = Array.from(new Set(stats.recent.map((r) => r.issue_id).filter(Boolean))); @@ -432,7 +425,7 @@ export default function AgentDetailPage({ {t("loop.agent.successAvg", { values: { pct: stats.successPct, avg: formatDurationMs(stats.avgMs) } })}
    -
    +
    {weeks.map((w, wi) => (
    {w.map((d) => ( diff --git a/packages/dmloop/src/panel/ModelPicker.tsx b/packages/dmloop/src/panel/ModelPicker.tsx index f5e4f1674..00fa1ad2c 100644 --- a/packages/dmloop/src/panel/ModelPicker.tsx +++ b/packages/dmloop/src/panel/ModelPicker.tsx @@ -2,7 +2,6 @@ import React, { useState } from "react"; import { Dropdown, Input, Toast } from "@douyinfe/semi-ui"; import { Plus } from "lucide-react"; import { useI18n } from "@octo/base"; -import EllipsisText from "../ui/EllipsisText"; /** * Agent 详情页模型选择(对齐 multica 的 model-picker 体验): @@ -27,7 +26,7 @@ export default function ModelPicker({ // 非 owner 只读:静态展示当前模型或「默认」,无弹框。 if (!canEdit) { - return ; + return {value || t("loop.agent.modelDefault")}; } const commit = async (next: string) => { @@ -90,7 +89,7 @@ export default function ModelPicker({ render={menu} > ); diff --git a/packages/dmloop/src/panel/RuntimePicker.tsx b/packages/dmloop/src/panel/RuntimePicker.tsx index 5929ae781..81a58e685 100644 --- a/packages/dmloop/src/panel/RuntimePicker.tsx +++ b/packages/dmloop/src/panel/RuntimePicker.tsx @@ -4,7 +4,6 @@ import { Cloud, Monitor, Lock, Check } from "lucide-react"; import { useI18n } from "@octo/base"; import type { RuntimeDevice } from "../api/types"; import { ProviderLogo } from "../ui/providerLogo"; -import EllipsisText from "../ui/EllipsisText"; import { deviceName } from "../pages/runtimeDevices"; type Filter = "mine" | "all"; @@ -96,7 +95,7 @@ export default function RuntimePicker({ return ( - + {selected?.name ?? "—"} {selected && dot(selected.status === "online")} ); @@ -172,7 +171,7 @@ export default function RuntimePicker({ diff --git a/packages/dmloop/src/panel/SkillDetailPage.tsx b/packages/dmloop/src/panel/SkillDetailPage.tsx index d49c442ad..54d472828 100644 --- a/packages/dmloop/src/panel/SkillDetailPage.tsx +++ b/packages/dmloop/src/panel/SkillDetailPage.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from "react"; -import { Typography, Input, Spin, Toast, Banner, Tooltip } from "@douyinfe/semi-ui"; +import { Typography, Input, Button, Spin, Toast, Banner, Tooltip } from "@douyinfe/semi-ui"; import LoopButton from "../ui/LoopButton"; import { ArrowLeft, BookOpen, Clock3, ExternalLink, Save, Trash2, Plus, Users } from "lucide-react"; import { useI18n, WKApp } from "@octo/base"; @@ -180,7 +180,7 @@ export default function SkillDetailPage({ skillId, onChanged }: { skillId: strin if (loading) return
    ; if (error || !row) return (
    -
    } onClick={back}>{t("loop.detail.back")}
    +
    {error ? : {t("loop.detail.notFound")}}
    ); @@ -190,10 +190,10 @@ export default function SkillDetailPage({ skillId, onChanged }: { skillId: strin return (
    - } onClick={back}>{t("loop.detail.back")} + {t("loop.detail.skillTitle")}
    - } onClick={remove}>{t("loop.action.delete")} + } disabled={!dirty} onClick={save}>{t("loop.action.save")}
    @@ -246,7 +246,7 @@ export default function SkillDetailPage({ skillId, onChanged }: { skillId: strin
    {t("loop.skill.detail.files")}({filePaths.length}) - } onClick={() => setAddingFile(true)} /> +
    {addingFile && ( @@ -262,7 +262,7 @@ export default function SkillDetailPage({ skillId, onChanged }: { skillId: strin {addError &&
    {addError}
    }
    {t("loop.skill.detail.addFile.add")} - {t("loop.action.cancel")} +
    )} @@ -276,9 +276,9 @@ export default function SkillDetailPage({ skillId, onChanged }: { skillId: strin
    {selectedPath !== SKILL_MD && (
    - } onClick={deleteSelectedFile}> +
    )} diff --git a/packages/dmloop/src/panel/agentDetail.css b/packages/dmloop/src/panel/agentDetail.css index 1e24a2cd2..7935f55c7 100644 --- a/packages/dmloop/src/panel/agentDetail.css +++ b/packages/dmloop/src/panel/agentDetail.css @@ -134,11 +134,7 @@ overflow-x: auto; padding-bottom: 2px; flex: 0 1 auto; - /* 小屏放不下时仍可横向滚动,但隐藏丑陋的滚动条(默认滚到最右=最新,见 AgentDetailPage) */ - scrollbar-width: none; - -ms-overflow-style: none; } -.loop-adp__cal::-webkit-scrollbar { display: none; } .loop-adp__cal-col { display: flex; flex-direction: column; gap: 3px; flex: 0 0 auto; } .loop-adp__cal-cell { width: 12px; @@ -474,7 +470,7 @@ .loop-adp__props { display: grid; - grid-template-columns: 72px minmax(0, 1fr); + grid-template-columns: 72px 1fr; gap: 0 8px; align-items: baseline; margin: 0; @@ -514,15 +510,6 @@ } .loop-adp__edit:hover { background: var(--semi-color-fill-0, #f5f6f8); } .loop-adp__edit-val { min-width: 0; word-break: break-word; } -/* 单行省略工具类(配合 EllipsisText:仅截断时 hover title 全名)。定义在 __edit-val 之后以覆盖其 word-break。 */ -.loop-ellipsis-1 { - display: block; - min-width: 0; - max-width: 100%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} .loop-adp__edit--empty .loop-adp__edit-val { font-style: italic; color: var(--semi-color-text-3, #b8bcc8); } .loop-adp__edit-ico { flex: 0 0 auto; diff --git a/packages/dmloop/src/ui/loopControls.css b/packages/dmloop/src/ui/loopControls.css index 62fa8c399..76fe912a9 100644 --- a/packages/dmloop/src/ui/loopControls.css +++ b/packages/dmloop/src/ui/loopControls.css @@ -369,14 +369,6 @@ color: var(--semi-color-text-0, #1c1f23); } -/* danger:无边框红字(删除/破坏性内联操作),hover 叠浅红底、色相锁死只加深 */ -.loop-btn--danger { background: transparent; color: var(--semi-color-danger, #d0453e); } -.loop-btn--danger:hover:not(:disabled) { - background: var(--semi-color-danger-light-default, #fdeceb); - color: var(--semi-color-danger-hover, #b83a34); -} -.loop-btn--danger:active:not(:disabled) { color: var(--semi-color-danger-active, #a1322d); } - .loop-btn:disabled { opacity: 0.45; cursor: not-allowed; } .loop-btn__spin { animation: loop-btn-spin 0.7s linear infinite; } diff --git a/packages/dmpersonal/src/PersonalPage.tsx b/packages/dmpersonal/src/PersonalPage.tsx index b4a557057..0fdb63460 100644 --- a/packages/dmpersonal/src/PersonalPage.tsx +++ b/packages/dmpersonal/src/PersonalPage.tsx @@ -9,7 +9,6 @@ import { import PersonalSidebarView from "./ui/PersonalSidebarView"; import PersonalWorkspaceState from "./ui/PersonalWorkspaceState"; import "@octo/loop/src/pages/loop.css"; -import "@octo/loop/src/ui/loopControls.css"; import "./personal.css"; function renderTab(key: PersonalTabKey): JSX.Element { diff --git a/packages/dmworkbase/src/Components/FilePreviewPanel/renderers/PptRenderer.css b/packages/dmworkbase/src/Components/FilePreviewPanel/renderers/PptRenderer.css index c1b69ccdb..c13ebf89a 100644 --- a/packages/dmworkbase/src/Components/FilePreviewPanel/renderers/PptRenderer.css +++ b/packages/dmworkbase/src/Components/FilePreviewPanel/renderers/PptRenderer.css @@ -152,8 +152,6 @@ margin: 0 2px; } -.wk-file-preview-ppt-page__page-total {} - /* ===== 内容区域 ===== */ .wk-file-preview-ppt-page__content { position: relative; diff --git a/packages/dmworkbase/src/Components/NavRail/NavSettingsPanel.tsx b/packages/dmworkbase/src/Components/NavRail/NavSettingsPanel.tsx index 148490581..6aa9627e5 100644 --- a/packages/dmworkbase/src/Components/NavRail/NavSettingsPanel.tsx +++ b/packages/dmworkbase/src/Components/NavRail/NavSettingsPanel.tsx @@ -174,7 +174,7 @@ export default class NavSettingsPanel extends Component {t("base.navRail.settingsPanel.spaceManagement")}
  • diff --git a/packages/dmworkbase/src/Components/WKModal/index.tsx b/packages/dmworkbase/src/Components/WKModal/index.tsx index c144f8582..0ebe7f53c 100644 --- a/packages/dmworkbase/src/Components/WKModal/index.tsx +++ b/packages/dmworkbase/src/Components/WKModal/index.tsx @@ -108,8 +108,8 @@ const WKModal: React.FC = ({ footerConfig, options, style, - zIndex, bodyStyle, + zIndex, header: customHeader, className, children, @@ -136,10 +136,10 @@ const WKModal: React.FC = ({ mask={mask} closeOnEsc={closeOnEsc} centered + zIndex={zIndex} className={cls} modalContentClass="wk-modal-content" style={style} - zIndex={zIndex} >
    {closable && ( diff --git a/packages/dmworkbase/src/Pages/Chat/__tests__/vm.channelListener.test.ts b/packages/dmworkbase/src/Pages/Chat/__tests__/vm.channelListener.test.ts index 49dd77f55..4df7cac99 100644 --- a/packages/dmworkbase/src/Pages/Chat/__tests__/vm.channelListener.test.ts +++ b/packages/dmworkbase/src/Pages/Chat/__tests__/vm.channelListener.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it, vi } from "vitest" // 捕获 ChatVM.channelListener(didMount 里通过 channelManager.addListener 注册)。 const hoisted = vi.hoisted(() => ({ channelListener: undefined as undefined | ((channelInfo: any) => void), + spaceChangedHandler: undefined as undefined | ((space: any) => void), + popToRoot: vi.fn(), })) vi.mock("wukongimjssdk", () => ({ @@ -75,9 +77,16 @@ vi.mock("../../../App", () => ({ notifyListener: () => {}, }, config: { appName: "Octo" }, - mittBus: { emit: () => {}, on: () => {}, off: () => {} }, + currentMenuId: "chat", + mittBus: { + emit: () => {}, + on: (event: string, handler: (payload: any) => void) => { + if (event === "space-changed") hoisted.spaceChangedHandler = handler + }, + off: () => {}, + }, menus: { refresh: () => {} }, - routeRight: { popToRoot: () => {} }, + routeRight: { popToRoot: hoisted.popToRoot }, endpointManager: { invoke: () => {} }, conversationProvider: { clearConversationMessages: () => Promise.resolve() }, apiClient: { get: () => Promise.resolve({}) }, @@ -136,6 +145,7 @@ vi.mock("../../../Utils/download", () => ({ })) import { ChatVM } from "../vm" +import WKApp from "../../../App" // 真实 Const 值:子区频道 channelType = 5 const ChannelTypeCommunityTopic = 5 @@ -213,3 +223,25 @@ describe("ChatVM.channelListener — CommunityTopic 子区同步 (issue #345)", expect(notifySpy).toHaveBeenCalledTimes(2) }) }) + +describe("ChatVM.spaceChangedHandler", () => { + it("does not clear the shared right pane while Chat is mounted in the background", () => { + mountVM() + ;(WKApp as any).currentMenuId = "mcp-market" + hoisted.popToRoot.mockClear() + + hoisted.spaceChangedHandler!({ space_id: "space-next" }) + + expect(hoisted.popToRoot).not.toHaveBeenCalled() + }) + + it("clears the shared right pane when Chat is the active menu", () => { + mountVM() + ;(WKApp as any).currentMenuId = "chat" + hoisted.popToRoot.mockClear() + + hoisted.spaceChangedHandler!({ space_id: "space-next" }) + + expect(hoisted.popToRoot).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/dmworkbase/src/Pages/Chat/vm.ts b/packages/dmworkbase/src/Pages/Chat/vm.ts index d47c612ef..88516fb21 100644 --- a/packages/dmworkbase/src/Pages/Chat/vm.ts +++ b/packages/dmworkbase/src/Pages/Chat/vm.ts @@ -154,8 +154,11 @@ export class ChatVM extends ProviderListener { this.selectedConversation = undefined // 清空选中的会话 WKApp.shared.openChannel = undefined // 清空全局打开的频道 this._showChannelSetting = false // 关闭频道设置面板 - // 强制关闭右侧聊天窗口,防止跨 Space 消息污染 - WKApp.routeRight.popToRoot() + // 强制关闭右侧聊天窗口,防止跨 Space 消息污染。routeRight 是全局共享右栏; + // Chat 常驻在隐藏 tab 时不能清空当前激活模块(如市场/Loop/Personal)的右栏。 + if (WKApp.currentMenuId === "chat") { + WKApp.routeRight.popToRoot() + } WKApp.shared.notifyListener() this.requestConversationList() } diff --git a/packages/dmworkbase/src/Service/APIClient.ts b/packages/dmworkbase/src/Service/APIClient.ts index 559db67fb..5a3cd9620 100644 --- a/packages/dmworkbase/src/Service/APIClient.ts +++ b/packages/dmworkbase/src/Service/APIClient.ts @@ -220,7 +220,16 @@ export class RequestConfig { * server-side renders (large document export) need a higher ceiling. */ timeout?: number - /** Cancels stale searches and other request/response races. */ + /** + * `AbortSignal` for per-request cancellation. Two active consumers today: + * - SearchService.ts (list/message search) — passes the query's own + * signal so a stale search cancels its in-flight HTTP. + * - GlobalMessageSearchService.ts — same pattern for global search. + * Removing this field silently drops the cancel contract (axios still + * runs to completion, but the abort controller thinks it worked), so + * stale requests keep hitting the server. Reinstated per PR#851 P1 by + * yujiawei. + */ signal?: AbortSignal } diff --git a/packages/dmworkbase/src/Service/Route.tsx b/packages/dmworkbase/src/Service/Route.tsx index 0993411fa..185484345 100644 --- a/packages/dmworkbase/src/Service/Route.tsx +++ b/packages/dmworkbase/src/Service/Route.tsx @@ -3,9 +3,35 @@ import WKApp from "../App"; import { EndpointCategory, EndpointID } from "./Const"; import { EndpointManager } from "./Module"; import { normalizeRoutePath } from "./RoutePath"; -import { ensureSessionSid } from "./SessionScope"; +import { ensureSessionSid, stripSessionSidFromUrl } from "./SessionScope"; + +/** + * Options for `RouteManager.register`. Kept optional so every existing + * `register(path, handler)` call site (upstream `/`, `/contacts`, and + * every summary/todo/… module) keeps its old semantics with no change. + * + * `hostShell` is the opt-in escape hatch that fixes the "refresh a + * sidebar-level URL and the whole page collapses to a bare sidebar" + * regression (PR#851 review 🔴, dmworkmcp `/mcp-market*`). See the + * `renderCurrentPath` comment below for the full story. When set, + * `renderCurrentPath` mounts the shell into host content and lets the + * shell's own URL-driven code (`syncMenuFromBrowserPath`) re-derive the + * active NavRail entry and right-pane — so refresh/back/copy-link land + * on the intended page with sidebar and NavRail intact. `handler(param)` + * is still used verbatim by `MainContentLeft` (via `route.get`) for the + * in-shell sidebar mount, so the two contexts get the component they + * each want without conflict. + */ +export interface RouteRegisterOptions { + hostShell?: () => JSX.Element; +} export default class RouteManager { + // Per-path host-shell factories registered with `register(..., { hostShell })`. + // Absent path → path is not shell-scoped → renderCurrentPath falls back to + // the pre-fix behaviour (restContent(handlerResult)). + private hostShells: Map JSX.Element> = new Map(); + private handlePopState = () => { RouteManager.shared.renderCurrentPath(window.location.pathname) } @@ -18,6 +44,12 @@ export default class RouteManager { window.addEventListener('popstate', this.handlePopState); window.addEventListener('pageshow', this.handlePageShow); ensureSessionSid() + // Scrub the initial `?sid=` off the address bar (and browser history) + // now that the session id is cached in sessionStorage. Otherwise the + // sid lingers in Referer headers and back-stack — this restores the + // pre-consolidation behaviour that the boot sequence used to enforce + // in apps/web/src/index.tsx. + stripSessionSidFromUrl() this.currentPath = normalizeRoutePath(window.location.pathname) } public static shared = new RouteManager() @@ -29,11 +61,22 @@ export default class RouteManager { currentPath?:string // 当前路由path - register(path: string, handler: (param: any) => JSX.Element| React.ElementType) { + register( + path: string, + handler: (param: any) => JSX.Element | React.ElementType, + options?: RouteRegisterOptions, + ) { const routePath = normalizeRoutePath(path) EndpointManager.shared.setMethod(`${EndpointID.routePrefix}${routePath}`, (param) => { return handler(param); }, { category: EndpointCategory.routes }); + if (options?.hostShell) { + this.hostShells.set(routePath, options.hostShell); + } else { + // Unregister any previously-declared shell so a caller re-registering + // without `hostShell` returns to the plain-host behaviour. + this.hostShells.delete(routePath); + } } get(path: string, param?: any): JSX.Element| React.ElementType { @@ -56,9 +99,33 @@ export default class RouteManager { window.history.pushState({}, "title", routePath) } + /** + * Compute what to render into host content for the given URL. Fired on + * cold-load / bfcache pageshow (via `handlePageShow`) and on + * back/forward (`handlePopState`) — the two entry points where the + * only thing that changed is the URL, not any in-app action. + * + * Two paths: + * 1) Path has a `hostShell` opted in at register time → mount the + * shell into host content. The shell's own URL-driven logic + * (ChatPage → syncMenuFromBrowserPath → NavRail menu.onPress) + * then re-derives the active menu + sidebar + right pane from + * the URL. This is the fix for `/mcp-market*` (and any future + * sidebar-level route) collapsing the whole page to a bare + * sidebar on refresh (PR#851 review 🔴). + * 2) No hostShell → old behaviour verbatim: the handler's output + * becomes the whole host. Kept so upstream `/` (registered as + * ChatPage) and any legacy standalone routes (e.g. login-only + * pages, /d/:docId cold-loads) are byte-identical to before. + */ renderCurrentPath(path: string, param?: any) { const routePath = normalizeRoutePath(path) this.currentPath = routePath + const shell = this.hostShells.get(routePath); + if (shell) { + WKApp.shared.restContent(shell()); + return; + } const component = EndpointManager.shared.invoke(`${EndpointID.routePrefix}${routePath}`, param) if (component) { WKApp.shared.restContent(component) @@ -68,6 +135,22 @@ export default class RouteManager { push(path: string, param?: any) { const routePath = normalizeRoutePath(path) this.currentPath = routePath + const shell = this.hostShells.get(routePath); + if (shell) { + // Same push URL semantics as before, but ensure a URL-driven + // navigation into a shell-scoped route mounts the shell (not the + // raw sidebar). Consumers that specifically want the sidebar + // component in the current shell should call `WKApp.route.get(path)` + // + `WKApp.routeLeft.replaceToRoot(...)` themselves. + const url = new URL(routePath, window.location.origin) + const nextUrl = url.pathname + url.search + const currentUrl = window.location.pathname + window.location.search + if (currentUrl !== nextUrl) { + window.history.pushState({}, "title", nextUrl) + } + WKApp.shared.restContent(shell()) + return; + } const component = EndpointManager.shared.invoke(`${EndpointID.routePrefix}${routePath}`, param) if (component) { const url = new URL(routePath, window.location.origin) diff --git a/packages/dmworkbase/src/Service/__tests__/RoutePath.test.ts b/packages/dmworkbase/src/Service/__tests__/RoutePath.test.ts deleted file mode 100644 index f7ef4f8eb..000000000 --- a/packages/dmworkbase/src/Service/__tests__/RoutePath.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeRoutePath } from "../RoutePath"; - -describe("normalizeRoutePath", () => { - it("keeps the root route as root", () => { - expect(normalizeRoutePath("/")).toBe("/"); - expect(normalizeRoutePath("")).toBe("/"); - expect(normalizeRoutePath(undefined)).toBe("/"); - }); - - it("removes trailing slashes from non-root routes", () => { - expect(normalizeRoutePath("/appbot/")).toBe("/appbot"); - expect(normalizeRoutePath("/appbot///")).toBe("/appbot"); - }); - - it("adds the leading slash when callers pass a bare route", () => { - expect(normalizeRoutePath("appbot")).toBe("/appbot"); - }); - - it("drops query and hash fragments before matching route handlers", () => { - expect(normalizeRoutePath("/appbot/?sid=abc")).toBe("/appbot"); - expect(normalizeRoutePath("/appbot#section")).toBe("/appbot"); - }); -}); diff --git a/packages/dmworkbase/src/__tests__/App.logoutRoute.test.ts b/packages/dmworkbase/src/__tests__/App.logoutRoute.test.ts deleted file mode 100644 index 9c3488103..000000000 --- a/packages/dmworkbase/src/__tests__/App.logoutRoute.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from "vitest"; -import * as fs from "fs"; -import * as path from "path"; - -const packageRoot = path.resolve(__dirname, "../.."); - -function readRepoFile(relativePath: string): string { - return fs.readFileSync(path.join(packageRoot, relativePath), "utf-8"); -} - -describe("WKApp logout route reset", () => { - it("does not reload the pre-logout business route", () => { - const source = readRepoFile("src/App.tsx"); - const logoutStart = source.indexOf(" logout() {"); - const logoutEnd = source.indexOf(" async logoutUserInitiated()", logoutStart); - const logoutSource = source.slice(logoutStart, logoutEnd); - - expect(logoutSource).toContain('window.location.replace("/login")'); - expect(source).toContain('setSessionSid("")'); - expect(logoutSource).not.toContain("window.location.reload()"); - }); -}); - -describe("RouteManager browser history handling", () => { - it("renders browser history events without pushing a new history entry", () => { - const source = readRepoFile("src/Service/Route.tsx"); - const popStart = source.indexOf(" private handlePopState"); - const constructorStart = source.indexOf(" private constructor()", popStart); - const historyHandlersSource = source.slice(popStart, constructorStart); - const renderStart = source.indexOf(" renderCurrentPath("); - const pushStart = source.indexOf(" push(", renderStart); - const renderSource = source.slice(renderStart, pushStart); - - expect(historyHandlersSource).toContain("renderCurrentPath(window.location.pathname)"); - expect(historyHandlersSource).not.toContain(".push(window.location.pathname)"); - expect(renderSource).toContain("WKApp.shared.restContent(component)"); - expect(renderSource).not.toContain("window.history.pushState"); - }); -}); diff --git a/packages/dmworkbase/src/index.tsx b/packages/dmworkbase/src/index.tsx index ea7ea8850..fc163def2 100644 --- a/packages/dmworkbase/src/index.tsx +++ b/packages/dmworkbase/src/index.tsx @@ -78,6 +78,8 @@ export { default as WKButton } from "./Components/WKButton" export { default as WKInput } from "./Components/WKInput" export { default as WKModal } from "./Components/WKModal" export type { WKModalProps, WKModalSize, WKModalFooterConfig } from "./Components/WKModal" +export { wkConfirm } from "./Components/WKModal" +export type { WKConfirmProps } from "./Components/WKModal" export { default as GroupAvatarPreview } from "./Components/GroupAvatarPreview" export type { GroupAvatarPreviewProps } from "./Components/GroupAvatarPreview" export { default as GroupAvatarEditModal } from "./Components/GroupAvatarEditModal" diff --git a/packages/dmworklogin/src/bind/BindPage.tsx b/packages/dmworklogin/src/bind/BindPage.tsx index 073c0a24d..bcd13a13e 100644 --- a/packages/dmworklogin/src/bind/BindPage.tsx +++ b/packages/dmworklogin/src/bind/BindPage.tsx @@ -66,7 +66,8 @@ function deriveCreateState(info: BindInfoResp): CreateState { interface BindPageProps { // 由 BindModule.init() 在 RouteManager 的 pageshow handler 冲掉 URL 之前 // 抓到的 location.search 快照. 不直接读 window.location.search 是因为 - // RouteManager 曾在 pageshow 时改写 URL, 可能把 bind 入口参数全部丢掉. + // RouteManager 会在 pageshow 时 push 一个带 sid= 的 URL, 把 bind 入口参数 + // 全部丢掉. initialSearch: string } diff --git a/packages/dmworklogin/src/bind/bindModule.tsx b/packages/dmworklogin/src/bind/bindModule.tsx index e5e86be4b..06d679b52 100644 --- a/packages/dmworklogin/src/bind/bindModule.tsx +++ b/packages/dmworklogin/src/bind/bindModule.tsx @@ -3,9 +3,9 @@ import { WKApp, IModule } from '@octo/base' import BindPage from './BindPage' // 在 module init 时 (startup 同步阶段, 早于 RouteManager 的 pageshow handler) -// 抓住 location.search 快照。RouteManager / 宿主路由后续可能把地址归一到 -// pathname,导致 bind 入口参数 (token / authcode / return_to / provider) 从 -// live URL 消失;BindPage 再去读 window.location.search 就拿不到了。 +// 抓住 location.search 快照. RouteManager 的 pageshow 监听器会 push 一个带 +// sid= 的新 URL, 把 bind 入口参数 (token / authcode / return_to / provider) +// 一起冲掉; BindPage 再去读 window.location.search 就拿不到了. // // 这个 snapshot 在 BindModule.init() 调用瞬间 capture, 然后通过 prop 注入, // 比 useEffect 里读 window.location.search 更早, 也更确定. @@ -27,10 +27,11 @@ export default class BindModule implements IModule { if (typeof window !== 'undefined' && window.location.pathname === '/oidc/bind') { bindInitialSearch = window.location.search // Scrub the live URL *synchronously* here, before RouteManager's - // pageshow handler has a chance to normalize or push another route entry. - // If we wait for BindPage's useEffect, replaceState there can leave the - // original `?token=...` entry behind in the Back stack — pressing Back - // exposes the bind token via address bar / referrer. + // pageshow handler runs window.history.pushState to add the sid URL on + // top. If we wait for BindPage's useEffect, the current entry is + // already the sid URL (see Route.tsx push()), and replaceState there + // leaves the original `?token=...` entry behind in the Back stack — + // pressing Back exposes the bind token via address bar / referrer. // The snapshot above keeps the params available to BindPage via prop, // so wiping window.location.search is safe. try { diff --git a/packages/dmworkmcp/package.json b/packages/dmworkmcp/package.json new file mode 100644 index 000000000..3405843ab --- /dev/null +++ b/packages/dmworkmcp/package.json @@ -0,0 +1,22 @@ +{ + "name": "@dmwork/mcp", + "version": "1.0.0", + "main": "src/index.tsx", + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@octo/base": "workspace:*", + "@dmwork/skillmarket": "workspace:*", + "@douyinfe/semi-icons": "^2.93.0", + "@douyinfe/semi-ui": "^2.93.0", + "axios": "^0.25.0", + "classnames": "^2.3.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^13.4.0", + "react": "^17.0.2", + "react-dom": "^17.0.2" + } +} diff --git a/packages/dmworkmcp/src/api/mcpListError.test.ts b/packages/dmworkmcp/src/api/mcpListError.test.ts new file mode 100644 index 000000000..6ff6c8da4 --- /dev/null +++ b/packages/dmworkmcp/src/api/mcpListError.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { classifyMcpListError, executeMcpListRequest, McpListError, mcpListErrorI18nKey } from "./mcpListError"; + +describe("classifyMcpListError", () => { + it.each([[401, "auth"], [403, "forbidden"], [500, "server"], [503, "server"]])("maps http %s", (status, expected) => { + expect(classifyMcpListError({ response: { status } })).toBe(expected); + }); + it("maps network errors without a response", () => { + expect(classifyMcpListError({ code: "ERR_NETWORK" })).toBe("network"); + }); + it.each([[401, "auth"], [403, "forbidden"], [500, "server"]])("preserves classification through the service request boundary", async (status, kind) => { + await expect(executeMcpListRequest(() => Promise.reject({ response: { status } }))).rejects.toMatchObject({ name: "Error", kind }); + }); + it("preserves an already classified page error", async () => { + const error = new McpListError("network"); + await expect(executeMcpListRequest(() => Promise.reject(error))).rejects.toBe(error); + }); + it("carries a service failure through to the page-specific error key", async () => { + try { await executeMcpListRequest(() => Promise.reject({ response: { status: 403 } })); } + catch (err) { expect(mcpListErrorI18nKey(err)).toBe("mcp.list.error.forbidden"); } + }); +}); diff --git a/packages/dmworkmcp/src/api/mcpListError.ts b/packages/dmworkmcp/src/api/mcpListError.ts new file mode 100644 index 000000000..c1f5fb0c4 --- /dev/null +++ b/packages/dmworkmcp/src/api/mcpListError.ts @@ -0,0 +1,27 @@ +export type McpListErrorKind = "auth" | "forbidden" | "network" | "server" | "unknown"; + +export class McpListError extends Error { + constructor(readonly kind: McpListErrorKind) { super(kind); } +} + +export function classifyMcpListError(err: unknown): McpListErrorKind { + const value = err as { response?: { status?: number }; code?: string }; + const status = value?.response?.status; + if (status === 401) return "auth"; + if (status === 403) return "forbidden"; + if (!value?.response && (value?.code === "ERR_NETWORK" || value?.code === "ECONNABORTED")) return "network"; + if (status && status >= 500) return "server"; + return "unknown"; +} + +export async function executeMcpListRequest(request: () => Promise): Promise { + try { return await request(); } + catch (err) { + if (err instanceof McpListError) throw err; + throw new McpListError(classifyMcpListError(err)); + } +} + +export function mcpListErrorI18nKey(err: unknown): string { + return `mcp.list.error.${err instanceof McpListError ? err.kind : "unknown"}`; +} diff --git a/packages/dmworkmcp/src/api/mcpService.ts b/packages/dmworkmcp/src/api/mcpService.ts new file mode 100644 index 000000000..c55d45cf6 --- /dev/null +++ b/packages/dmworkmcp/src/api/mcpService.ts @@ -0,0 +1,881 @@ +import axios, { AxiosRequestConfig } from "axios"; +import { WKApp, buildAcceptLanguage, t, DEFAULT_REQUEST_TIMEOUT_MS } from "@octo/base"; +import type { + CreateMcpParams, + ListMcpParams, + ListMcpResponse, + McpCategory, + McpDetail, + McpListItem, + McpProbeRequest, + McpProbeResult, + McpQuickStart, + UpdateMcpParams, +} from "../types/mcp"; +import { + MCP_CATEGORY_LABELS, + MCP_CATEGORY_ORDER, + MOCK_MCP_DETAILS, + MOCK_MCP_LIST, + MOCK_PROBED_TOOLS, +} from "../mock/mcpMock"; +import { CATEGORY_KEY_ALL, slugifyServerName } from "../utils/constants"; +import { McpListError, classifyMcpListError, executeMcpListRequest } from "./mcpListError"; + +// ═══════════════════════════════════════════════════════════════════════════ +// MCP Market service layer +// ═══════════════════════════════════════════════════════════════════════════ +// +// The UI (list page + detail/create modals) ONLY imports the exported +// functions below — it never talks to axios or the mock directly. This keeps +// data-fetching behind a single seam so switching from mock to the real +// backend is a one-line change. +// +// ┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ +// │ Pages/UI │ ──▶ │ this service │ ──▶ │ mock OR api │ +// └─────────────┘ └──────────────────┘ └─────────────┘ +// +// Public surface (stable signatures — the UI never sees mock vs real): +// fetchMcpList(params) → list + categories +// fetchMcpMine(params) → list restricted to caller-owned records +// fetchMcpDetail(id) → full detail +// probeMcpTools(req) → "try connect / fetch tool list" (see LSC-70) +// createMcp(params) → create a new MCP entry +// updateMcp(id, params) → PATCH — owner-only partial update +// deleteMcp(id) → DELETE — owner-only soft delete +// +// The real implementations target the octo-marketplace MCP catalog v1 +// (octo-marketplace/docs/api/mcp-v1.md). USE_MOCK toggles the whole surface; +// browse + create now run against the real backend. The request plumbing +// (axios instance + interceptors) mirrors the summary module +// (packages/dmworksummary/src/api/summaryApi.ts) so auth / space-id / language +// headers stay consistent across the app. +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Single switch between mock and real implementations. + * Keep as a const so the bundler tree-shakes the unused branch in prod. + */ +const USE_MOCK = false; + +// Simulate network latency so loading states are exercised during dev. +const MOCK_DELAY_MS = 300; + +function delay(value: T, ms = MOCK_DELAY_MS): Promise { + return new Promise((resolve) => setTimeout(() => resolve(value), ms)); +} + +/** + * Reject presigned upload / download URLs whose scheme is not http(s), or + * whose http-scheme host is not a loopback (dev proxy). Blocks the obvious + * bad schemes — `javascript:`, `data:`, `file:` — before an anchor.href / + * axios.put reaches them. + * + * Scope: this is scheme-level defense-in-depth only. An `https://` URL + * pointing at an internal / metadata host (`https://10.x`, + * `https://169.254.169.254`) still passes; that class of concern needs a + * host allowlist against the known storage origin, which the marketplace + * hasn't published yet. Blast radius is bounded either way — the PUT + * carries only the user-selected icon bytes with no app credentials (raw + * axios, no interceptors). + */ +function assertSafeUploadURL(raw: string): void { + let u: URL; + try { + u = new URL(raw); + } catch { + throw new Error(t("mcp.create.iconUploadFailed")); + } + if (u.protocol === "https:") return; + if (u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1")) return; + throw new Error(t("mcp.create.iconUploadFailed")); +} + +// ─── Mock implementations ────────────────────────────────────────────────── + +/** Category pill counts over an arbitrary MCP set. Callers pass the same + * filtered slice they showed as items, so pill numbers stay coherent with + * the visible list — matches the real backend's `/mcp_categories` which + * respects `created_by_type` (issue #894 follow-up). */ +function buildCategories(source: McpListItem[] = MOCK_MCP_LIST): McpCategory[] { + const counts = new Map(); + for (const item of source) { + counts.set(item.category, (counts.get(item.category) ?? 0) + 1); + } + return MCP_CATEGORY_ORDER.map((key) => ({ + key, + label: MCP_CATEGORY_LABELS[key] ?? key, + count: key === "all" ? source.length : counts.get(key) ?? 0, + })); +} + +async function fetchMcpListMock( + params: ListMcpParams +): Promise { + return fetchMcpListMockFiltered(params, MOCK_MCP_LIST); +} + +/** Mock counterpart of /mcps/mine — restricts to items whose `creatorName` + * matches the current login name. Mock has no real owner_uid, but new + * creates stamp the login name (see buildDetailFromCreate), so this + * faithfully echoes "MCPs I created in this session". */ +async function fetchMcpMineMock( + params: ListMcpParams +): Promise { + const me = WKApp.loginInfo?.name || ""; + const mine = MOCK_MCP_LIST.filter((item) => item.creatorName === me); + return fetchMcpListMockFiltered(params, mine); +} + +async function fetchMcpListMockFiltered( + params: ListMcpParams, + source: McpListItem[] +): Promise { + const keyword = (params.keyword ?? "").trim().toLowerCase(); + const category = params.category ?? "all"; + const createdBy = params.createdByType; + const filtered = source.filter((item) => { + const matchCategory = category === "all" || item.category === category; + const matchKeyword = + !keyword || + item.name.toLowerCase().includes(keyword) || + item.slogan.toLowerCase().includes(keyword); + // Legacy fixtures without createdByType are treated as human — same + // read-side default the wire mapper applies for pre-#894 records. + const rowType = item.createdByType ?? "human"; + const matchCreatedBy = !createdBy || rowType === createdBy; + return matchCategory && matchKeyword && matchCreatedBy; + }); + const offset = params.offset && params.offset > 0 ? params.offset : 0; + const limit = + params.limit && params.limit > 0 ? params.limit : filtered.length; + const items = filtered.slice(offset, offset + limit); + return delay({ + items, + total: filtered.length, + categories: buildCategories(filtered), + }); +} + +async function fetchMcpDetailMock(id: string): Promise { + const detail = MOCK_MCP_DETAILS.find((d) => d.id === id); + if (!detail) { + throw new Error(`MCP not found: ${id}`); + } + return delay(detail); +} + +async function probeMcpToolsMock( + req: McpProbeRequest +): Promise { + // Mock probe: pretend to connect and fetch tools/list. Longer delay so the + // loading state is visible. Real probing (esp. stdio) must be done by the + // Electron main process — see LSC-70. + // TODO: 后端提供真实探测接口 + const hasTarget = req.transport === "stdio" ? !!req.command : !!req.url; + if (!hasTarget) { + return delay( + { + ok: false, + tools: [], + // The UI translates by `code`; the service layer stays i18n-agnostic. + error: { + code: "init_failed" as const, + message: "", + }, + }, + 600 + ); + } + return delay( + { + ok: true, + tools: MOCK_PROBED_TOOLS, + serverInfo: { name: req.transport, version: "mock" }, + }, + 800 + ); +} + +async function createMcpMock(params: CreateMcpParams): Promise<{ id: string }> { + // In-memory persistence: mutate the same arrays fetchMcpList/Detail read + // from, so a freshly-created MCP shows up at the top of the list and its + // detail modal opens without a "not found" error. Session-only — a page + // reload resets to the built-in fixtures, which is what we want for a + // prototype (no leaking mock state across sessions). + const id = slugify(params.name) || `mock-${Date.now()}`; + const uniqueId = MOCK_MCP_DETAILS.some((d) => d.id === id) + ? `${id}-${Date.now().toString(36)}` + : id; + const detail = buildDetailFromCreate(uniqueId, params); + MOCK_MCP_DETAILS.unshift(detail); + MOCK_MCP_LIST.unshift(projectListItem(detail)); + return delay({ id: uniqueId }, 400); +} + +/** Mock counterpart of PATCH /mcps/{id}. Full-replace semantics: the UI + * always sends every field, so we rebuild the detail from the params and + * swap the list projection in place. */ +async function updateMcpMock( + id: string, + params: UpdateMcpParams +): Promise { + const idx = MOCK_MCP_DETAILS.findIndex((d) => d.id === id); + if (idx === -1) throw new Error(`MCP not found: ${id}`); + const prev = MOCK_MCP_DETAILS[idx]; + const next = buildDetailFromCreate(id, params); + // Preserve server-owned fields — the wire never lets the client change + // these, so the mock must match: creator identity and the provenance + // triple (issue #894). Otherwise a mock edit of a bot record would + // silently drop its 🤖 badge on the next read. + next.creatorName = prev.creatorName; + next.createdByType = prev.createdByType; + next.createdByBotUid = prev.createdByBotUid; + next.createdByBotName = prev.createdByBotName; + MOCK_MCP_DETAILS[idx] = next; + const listIdx = MOCK_MCP_LIST.findIndex((it) => it.id === id); + if (listIdx !== -1) MOCK_MCP_LIST[listIdx] = projectListItem(next); + return delay(next, 300); +} + +/** Mock counterpart of DELETE /mcps/{id}. Owner-only in the real service; + * the mock has no owner model so we always allow. */ +async function deleteMcpMock(id: string): Promise { + const dIdx = MOCK_MCP_DETAILS.findIndex((d) => d.id === id); + if (dIdx !== -1) MOCK_MCP_DETAILS.splice(dIdx, 1); + const lIdx = MOCK_MCP_LIST.findIndex((it) => it.id === id); + if (lIdx !== -1) MOCK_MCP_LIST.splice(lIdx, 1); + return delay(undefined, 300); +} + +/** Turn a create-form payload into a fully-populated detail record. */ +function buildDetailFromCreate(id: string, params: CreateMcpParams): McpDetail { + const quickStart: McpQuickStart = { + transport: params.transport, + serverName: params.name.trim(), + slug: slugifyServerName(params.slug?.trim() ? params.slug : params.name), + url: params.url || undefined, + authType: params.authType, + headers: + params.headers && Object.keys(params.headers).length + ? params.headers + : undefined, + command: params.command || undefined, + args: params.args && params.args.length ? params.args : undefined, + env: params.env && Object.keys(params.env).length ? params.env : undefined, + }; + return { + id, + name: params.name.trim(), + slogan: params.slogan, + category: params.category, + tags: params.tags ?? [], + toolCount: params.tools.length, + icon: params.icon, + creatorName: WKApp.loginInfo?.name || "", + quickStart, + tools: params.tools, + usageExamples: (params.usageExamples ?? []).filter((s) => s.trim()), + faqs: (params.faqs ?? []).filter((f) => f.question.trim()), + notes: (params.notes ?? []).filter((s) => s.trim()), + }; +} + +/** Derive the list-card projection from a full detail. Carries provenance + * through (issue #894) so a bot-created record keeps its 🤖 badge on the + * card view after create/update in USE_MOCK mode. */ +function projectListItem(d: McpDetail): McpListItem { + return { + id: d.id, + name: d.name, + slogan: d.slogan, + category: d.category, + tags: d.tags, + toolCount: d.toolCount, + icon: d.icon, + createdByType: d.createdByType, + createdByBotUid: d.createdByBotUid, + createdByBotName: d.createdByBotName, + creatorName: d.creatorName, + }; +} + +/** ASCII/CJK-safe slug for the mock id. Falls back to "" so caller adds ts. */ +function slugify(s: string): string { + return s + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9一-龥-]/g, ""); +} + +// ─── Real implementations (octo-marketplace MCP catalog v1) ───────────────── +// Wire contract: octo-marketplace/docs/api/mcp-v1.md. The catalog is mounted at +// /market/api/v1 (nginx / vite proxy strips the /market prefix to the +// service's own /api/v1), mirroring the summary + matter service convention. + +const mcpAxios = axios.create({ + baseURL: "", + // Isolated instance (no shared interceptors), so it never picks up the + // 20s default that APIClient.initAxios sets on the axios singleton — set + // the same ceiling explicitly to avoid the UI-hang class of bug that + // DEFAULT_REQUEST_TIMEOUT_MS was introduced to close. + timeout: DEFAULT_REQUEST_TIMEOUT_MS, + // Serialise array params as repeated keys (`?a=1&a=2`) instead of axios + // 0.25's default `?a[]=1&a[]=2`. gin's QueryArray on the marketplace + // backend only recognises the plain-repeat form; a bracketed key would + // silently become a single-string param that never matches. Also drops + // undefined/null keys so callers can just pass an optional value without + // pre-filtering. + paramsSerializer: (params) => { + const usp = new URLSearchParams(); + for (const [key, value] of Object.entries(params ?? {})) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) { + for (const item of value) { + if (item === undefined || item === null) continue; + usp.append(key, String(item)); + } + } else { + usp.append(key, String(value)); + } + } + return usp.toString(); + }, +}); + +const BASE = "/market/api/v1"; + +function resolveBaseURL(): string { + const apiURL = WKApp.apiClient?.config?.apiURL; + if (!apiURL) return ""; + try { + // Relative apiURL (Web) has no parsable origin → stay same-origin. + return new URL(apiURL).origin; + } catch { + return ""; + } +} + +mcpAxios.interceptors.request.use((config) => { + config.baseURL = resolveBaseURL(); + config.headers = config.headers ?? {}; + config.headers["Accept-Language"] = buildAcceptLanguage(); + const token = WKApp.loginInfo.token; + if (token) { + config.headers["token"] = token; + } + const spaceId = WKApp.shared.currentSpaceId; + if (spaceId) { + config.headers["X-Space-Id"] = spaceId; + } + return config; +}); + +mcpAxios.interceptors.response.use( + (resp) => resp, + (err) => { + if (err?.response?.status === 401) { + WKApp.shared.logout(); + } + return Promise.reject(err); + } +); + +/** + * Marketplace errors use the OCTO `{error:{code,message,details,hint}}` envelope. When we + * recognize the wire `code` we surface a localized copy so a Chinese UI + * doesn't show the backend's English `message`; unknown codes fall through to + * the wire message. Falls back to the axios error string when the body is + * missing. + */ +function extractErrorMessage(err: unknown): string { + const axiosErr = err as { + response?: { data?: { error?: { message?: string; code?: string } } }; + }; + const wire = axiosErr?.response?.data?.error; + const code = wire?.code; + const localized = code ? localizedForCode(code) : ""; + const raw = + localized || + wire?.message || + code || + (err instanceof Error ? err.message : "Request failed"); + return raw.length > 200 ? raw.slice(0, 200) + "…" : raw; +} + +/** Map a standard OCTO error code to a localized string via i18n. Returns + * empty string if the code is unknown; caller falls back to the wire + * message. Keeping the mapping table here keeps the i18n keys colocated + * with the codes and greppable. */ +function localizedForCode(code: string): string { + const KNOWN: Record = { + DUPLICATE: "mcp.errors.nameTaken", + VALIDATION_ERROR: "mcp.errors.invalidRequest", + FORBIDDEN: "mcp.errors.forbidden", + NOT_FOUND: "mcp.errors.notFound", + AUTH_REQUIRED: "mcp.errors.unauthorized", + INTERNAL_ERROR: "mcp.errors.internal", + }; + const key = KNOWN[code]; + return key ? t(key) : ""; +} + +/** + * Marketplace success bodies use the OCTO `{data:...}` envelope. + */ +async function get( + path: string, + params?: Record, + config?: AxiosRequestConfig +): Promise { + try { + const resp = await mcpAxios.get(`${BASE}${path}`, { params, ...config }); + return resp.data.data as T; + } catch (err) { + if (axios.isCancel(err)) throw err; + throw new McpListError(classifyMcpListError(err)); + } +} + +async function post(path: string, data?: unknown): Promise { + try { + const resp = await mcpAxios.post(`${BASE}${path}`, data); + return resp.data.data as T; + } catch (err) { + if (axios.isCancel(err)) throw err; + throw new Error(extractErrorMessage(err)); + } +} + +async function patch(path: string, data?: unknown): Promise { + try { + const resp = await mcpAxios.patch(`${BASE}${path}`, data); + return resp.data.data as T; + } catch (err) { + if (axios.isCancel(err)) throw err; + throw new Error(extractErrorMessage(err)); + } +} + +async function del(path: string): Promise { + try { + await mcpAxios.delete(`${BASE}${path}`); + } catch (err) { + if (axios.isCancel(err)) throw err; + throw new Error(extractErrorMessage(err)); + } +} + +/** + * Resolve a category label from the frontend i18n bundle. The backend returns + * `{key,count}` only (mcp-v1.md §4.2); labels are the frontend's job so locales + * evolve without a service redeploy. Falls back to the static map, then the raw + * key, so an unknown key still renders something sensible. + */ +function categoryLabel(key: string): string { + const translated = t(`mcp.category.${key}`); + // i18n returns the key path back on a miss — treat that as "no translation". + if (translated && translated !== `mcp.category.${key}`) return translated; + return MCP_CATEGORY_LABELS[key] ?? key; +} + +/** Wire shape of the list response before frontend label enrichment. */ +interface McpListItemWire { + mcp_id: string; + name: string; + slogan: string; + category: string; + icon: string; + tags: string[]; + tool_count: number; + visibility?: McpListItem["visibility"]; + creator_name?: string; + created_by_type?: McpListItem["createdByType"]; + created_by_bot_uid?: string; + created_by_bot_name?: string; + transport?: McpListItem["transport"]; + source?: McpListItem["source"]; + verification_status?: McpListItem["verificationStatus"]; + match_reasons?: string[]; + relevance?: number; + updated_at?: string; +} + +interface McpDetailWire extends McpListItemWire { + quick_start: { + transport: McpQuickStart["transport"]; + server_name: string; + slug?: string; + url?: string; + command?: string; + args?: string[]; + env?: Record; + headers?: Record; + auth_type?: "bearer" | "none"; + }; + tools: McpDetail["tools"]; + usage_examples: string[]; + faqs: McpDetail["faqs"]; + notes: string[]; + created_at?: string; + updated_at?: string; +} + +interface McpListResponseWire { + data: McpListItemWire[]; + pagination: { total: number; page: number; page_size: number }; +} + +function mapListItem(raw: McpListItemWire): McpListItem { + return { + id: raw.mcp_id, + name: raw.name ?? "", + // Fall back to empty string / 0 so downstream renderers that call + // .toLowerCase() (Highlight) or format the tool count don't crash on a + // null field slipping in from a legacy record or partial response. + slogan: raw.slogan ?? "", + category: raw.category, + icon: raw.icon, + tags: raw.tags ?? [], + toolCount: raw.tool_count ?? 0, + visibility: raw.visibility, + creatorName: raw.creator_name, + createdByType: raw.created_by_type, + createdByBotUid: raw.created_by_bot_uid, + createdByBotName: raw.created_by_bot_name, + transport: raw.transport, source: raw.source, + verificationStatus: raw.verification_status, + matchReasons: raw.match_reasons ?? [], relevance: raw.relevance, + updatedAt: raw.updated_at, + }; +} + +function mapDetail(raw: McpDetailWire): McpDetail { + const item = mapListItem(raw); + // Guard against a missing `quick_start` block on the wire — while + // McpDetailWire types it as required, a null/absent value from a legacy + // record or a partial backend response would otherwise crash the whole + // detail-modal fetch with `Cannot read properties of null`. Fall back to + // an empty stdio-shaped block so the modal renders with an empty + // quick-access tab instead of blowing up. + const q = raw.quick_start ?? ({} as McpDetailWire["quick_start"]); + return { + ...item, + quickStart: { + transport: q.transport ?? "stdio", + serverName: q.server_name ?? raw.name ?? "", + slug: q.slug, + url: q.url, + command: q.command, + args: q.args, + env: q.env, + headers: q.headers, + authType: q.auth_type, + }, + tools: raw.tools ?? [], + usageExamples: raw.usage_examples ?? [], + faqs: raw.faqs ?? [], + notes: raw.notes ?? [], + createdAt: raw.created_at, + updatedAt: raw.updated_at, + }; +} + +function toWireParams(params: CreateMcpParams | UpdateMcpParams) { + return { + name: params.name, + slug: params.slug, + slogan: params.slogan, + category: params.category, + icon: params.icon, + tags: params.tags, + transport: params.transport, + url: params.url, + command: params.command, + args: params.args, + env: params.env, + headers: params.headers, + auth_type: params.authType, + tools: params.tools, + usage_examples: params.usageExamples, + faqs: params.faqs, + notes: params.notes, + visibility: params.visibility, + }; +} + +async function fetchMcpListReal( + params: ListMcpParams +): Promise { + return fetchMcpListPath("/mcps", params); +} + +/** GET /mcps/mine — same shape, restricted to owner=caller (mcp-v1.md §4.3). */ +async function fetchMcpMineReal( + params: ListMcpParams +): Promise { + return fetchMcpListPath("/mcps/mine", params); +} + +/** Shared list-body handling: build query, hit path, enrich labels. */ +async function fetchMcpListPath( + path: string, + params: ListMcpParams +): Promise { + const query: Record = {}; + const keyword = params.keyword?.trim(); + if (keyword) query.keyword = keyword; + // `all` disables the filter server-side; send it verbatim per §0. + query.category = params.categories?.length ? params.categories[0] : (params.category ?? CATEGORY_KEY_ALL); + if (params.createdByType) { + query.created_by_type = params.createdByType; + } + // Relevance is only meaningful with a keyword — every row scores 0 otherwise, + // making the sort order arbitrary. When browsing, surface freshest first. + query.sort = keyword ? "relevance" : "updated"; + const pageSize = params.limit && params.limit > 0 ? params.limit : 20; + query.page_size = pageSize; + query.page = Math.floor((params.offset ?? 0) / pageSize) + 1; + // Category counts must honour the SAME `created_by_type` filter as the + // item list, otherwise the pill numbers become misleading when a source + // filter is active (issue #894 follow-up). `/mcps/mine` scopes to the + // caller via mode=mine; the source filter piggy-backs on top. Both are + // passed through the shared axios params serializer, so there's a single + // wire-shape truth for repeated-array values. + const categoryParams: Record = {}; + if (path === "/mcps/mine") categoryParams.mode = "mine"; + if (params.createdByType) categoryParams.created_by_type = params.createdByType; + const [resp, categoryWire] = await executeMcpListRequest(() => Promise.all([ + mcpAxios.get(`${BASE}${path}`, { params: query }), + mcpAxios + .get<{ data: { key: string; count: number }[] }>(`${BASE}/mcp_categories`, { + params: categoryParams, + }) + .then((r) => r.data.data), + ])); + const items = (resp.data.data ?? []).map(mapListItem); + const categoryCounts = new Map( + categoryWire.map((item) => [item.key, item.count]) + ); + const categories: McpCategory[] = MCP_CATEGORY_ORDER.map((key) => ({ + key, + label: categoryLabel(key), + count: categoryCounts.get(key) ?? 0, + })); + return { items, total: resp.data.pagination.total, categories }; +} + +async function fetchMcpDetailReal(id: string): Promise { + return get(`/mcps/${encodeURIComponent(id)}`).then(mapDetail); +} + +async function probeMcpToolsReal( + req: McpProbeRequest +): Promise { + // POST /mcps/probe runs an MCP initialize + tools/list handshake against a + // remote server and returns the wire shape below (mcp-v1.md §4.7). The + // endpoint returns HTTP 200 in both success and operational-failure cases + // (ok=false + in-body error). Only auth / malformed body / stdio transport + // return the standard error envelope with a non-2xx status; those become + // thrown Errors via post(), which the caller renders as a Toast. + // + // stdio transport is short-circuited here so we don't round-trip a request + // the server is guaranteed to reject with `probe_unsupported`. The wizard + // hides the button under `isProbeAvailable` anyway; this belt+braces path + // just returns a clean in-body error for any programmatic caller. + if (req.transport === "stdio") { + return { + ok: false, + tools: [], + error: { + code: "command_not_found", + message: "stdio probe must run in the desktop client", + }, + }; + } + const raw = await post<{ + is_ok: boolean; + tools: McpProbeResult["tools"]; + server_info?: McpProbeResult["serverInfo"]; + error?: McpProbeResult["error"]; + }>("/mcps/_probe", req); + return { + ok: raw.is_ok, + tools: raw.tools ?? [], + serverInfo: raw.server_info, + error: raw.error, + }; +} + +async function createMcpReal(params: CreateMcpParams): Promise<{ id: string }> { + // POST /mcps returns 201 with the full McpDetail; the frontend picks up `id` + // from the response (mcp-v1.md §4.1). Server derives id / creatorName / + // toolCount / timestamps and ignores any client-supplied values for them, so + // the flat create body is sent as-is (§3.3). + const detail = await post("/mcps", toWireParams(params)); + return { id: detail.mcp_id }; +} + +/** PATCH /mcps/{id} — owner-only partial update (mcp-v1.md §4.5). The UI + * always sends the full form, so every field is present and the backend + * effectively replaces all mutable fields; returns 200 with the updated + * McpDetail. 403 → forbidden, 404 → not_found are surfaced by the shared + * error mapper. */ +async function updateMcpReal( + id: string, + params: UpdateMcpParams +): Promise { + return patch( + `/mcps/${encodeURIComponent(id)}`, + toWireParams(params) + ).then(mapDetail); +} + +/** DELETE /mcps/{id} — owner-only soft delete (mcp-v1.md §4.6). Returns + * 204 No Content on success. */ +async function deleteMcpReal(id: string): Promise { + return del(`/mcps/${encodeURIComponent(id)}`); +} + +/** + * Upload an MCP icon and return the persisted URL to write onto the `icon` + * field. + * + * Uses marketplace's presigned URL flow (POST /api/v1/mcp/upload/icon) — + * same channel octo-admin uses. The client asks marketplace for a + * pre-signed PUT URL + a persistent download URL, PUTs the bytes directly + * to storage, then stores the download URL on the MCP record. The `id` + * parameter is ignored (kept for signature compatibility with the mock and + * older callers); marketplace assigns its own UUID to the object key. + * + * Prior implementation rode on the main IM's `file/upload/credentials` + * endpoint. Two upload channels for the same feature was operational churn + * — marketplace's own storage layer handles both admin and user paths now, + * so this frontend uses one. + */ +async function uploadMcpIconReal(_id: string, file: File): Promise { + interface McpIconInitResponse { + object_key: string; + presigned_url: string; + expires_in: number; + method: string; + headers: Record; + download_url: string; + } + + const init = await mcpAxios.post<{ data: McpIconInitResponse }>( + `${resolveBaseURL()}${BASE}/mcp_icon_uploads`, + { + file_name: file.name || "icon", + file_size: file.size, + content_type: file.type || "application/octet-stream", + } + ); + if ( + !init.data?.data?.presigned_url || + !init.data?.data?.download_url + ) { + throw new Error(t("mcp.create.iconUploadFailed")); + } + const { presigned_url, download_url, headers } = init.data.data; + // Defense-in-depth: the presigned URLs come back from our own marketplace + // backend, but any downstream misconfiguration/compromise could point them + // at an internal address or a non-HTTPS host. Only allow https:// (or + // http:// on localhost for dev proxies). + assertSafeUploadURL(presigned_url); + assertSafeUploadURL(download_url); + + // PUT the icon bytes through a dedicated axios instance with no + // interceptors. Prior implementation used the default `axios` singleton, + // but `packages/dmworkbase/src/Service/APIClient.ts` registers a GLOBAL + // request interceptor on that singleton which injects `token: ` + // and `X-Space-Id: ...` on every request. The presigned URL points at an + // external storage origin (not marketplace), so those headers would leak + // the caller's session token to a third-party host — flagged as P1 + // credential exposure in PR#851 review (yujiawei). `axios.create()` here + // is a fresh instance that never picked up the interceptor, so no + // credentials cross the origin. It also avoids the sibling risk of some + // S3/OSS presigners rejecting unsigned/unexpected headers with + // `SignatureDoesNotMatch`. + const rawAxios = axios.create(); + const putResp = await rawAxios.put(presigned_url, file, { + headers: headers ?? {}, + timeout: 2 * 60 * 1000, + // Disable axios's default JSON transform — we want the file bytes + // sent as-is, not stringified. + transformRequest: [(data) => data], + }); + if (!(putResp.status >= 200 && putResp.status < 300)) { + throw new Error(t("mcp.create.iconUploadFailed")); + } + return download_url; +} + +/** Mock icon upload — returns an object URL so the mock detail renders the + * freshly-picked image without a backend round-trip. */ +async function uploadMcpIconMock(_id: string, file: File): Promise { + return delay(URL.createObjectURL(file), 200); +} + +// ─── Public API (the only surface the UI imports) ────────────────────────── + +export function fetchMcpList( + params: ListMcpParams = {} +): Promise { + return USE_MOCK ? fetchMcpListMock(params) : fetchMcpListReal(params); +} + +/** GET /mcps/mine — restricted to the caller's own records. */ +export function fetchMcpMine( + params: ListMcpParams = {} +): Promise { + return USE_MOCK ? fetchMcpMineMock(params) : fetchMcpMineReal(params); +} + +export function fetchMcpDetail(id: string): Promise { + return USE_MOCK ? fetchMcpDetailMock(id) : fetchMcpDetailReal(id); +} + +/** + * Try-connect + fetch tool list. Mock returns a fake tool set after a delay; + * the real implementation is provided by the Electron main process (LSC-70). + */ +export function probeMcpTools(req: McpProbeRequest): Promise { + return USE_MOCK ? probeMcpToolsMock(req) : probeMcpToolsReal(req); +} + +/** + * Whether "try connect / fetch tool list" is actually wired up. Real remote + * probing (streamable-http / sse) is served by POST /mcps/probe on the + * marketplace backend (mcp-v1.md §4.7). stdio probing still requires the + * desktop client's Electron IPC (LSC-70) and is short-circuited to an in-body + * `command_not_found` error inside probeMcpToolsReal — the button surfaces + * regardless so the user can always kick off a remote probe. + */ +export const isProbeAvailable = true; + +export function createMcp(params: CreateMcpParams): Promise<{ id: string }> { + return USE_MOCK ? createMcpMock(params) : createMcpReal(params); +} + +/** PATCH /mcps/{id} — owner-only partial update. Returns the updated detail. */ +export function updateMcp( + id: string, + params: UpdateMcpParams +): Promise { + return USE_MOCK ? updateMcpMock(id, params) : updateMcpReal(id, params); +} + +/** DELETE /mcps/{id} — owner-only soft delete. */ +export function deleteMcp(id: string): Promise { + return USE_MOCK ? deleteMcpMock(id) : deleteMcpReal(id); +} + +/** + * Upload an MCP icon to object storage (POST /mcps/{id}/icon, multipart). + * Returns the persisted storage URL to store on the `icon` field. + */ +export function uploadMcpIcon(id: string, file: File): Promise { + return USE_MOCK ? uploadMcpIconMock(id, file) : uploadMcpIconReal(id, file); +} diff --git a/packages/dmworkmcp/src/api/quickStartTemplates.test.ts b/packages/dmworkmcp/src/api/quickStartTemplates.test.ts new file mode 100644 index 000000000..1869874ad --- /dev/null +++ b/packages/dmworkmcp/src/api/quickStartTemplates.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from "vitest"; +import { buildQuickStartTabs, TOKEN_PLACEHOLDER } from "./quickStartTemplates"; +import type { McpQuickStart } from "../types/mcp"; + +/** Small helper: grab a tab's content by key from the ordered tab list. */ +function content(qs: McpQuickStart, key: "prompt" | "json"): string { + const tab = buildQuickStartTabs(qs).find((t) => t.key === key); + if (!tab) throw new Error(`missing tab ${key}`); + return tab.content; +} + +describe("buildQuickStartTabs — JSON snippet", () => { + it("stdio: no `type` field, includes env when present", () => { + const qs: McpQuickStart = { + transport: "stdio", + serverName: "github", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + env: { FOO: "bar", GITHUB_TOKEN: "" }, + }; + const json = JSON.parse(content(qs, "json")); + const server = json.mcpServers.github; + expect(server.type).toBeUndefined(); + expect(server.command).toBe("npx"); + expect(server.args).toEqual(["-y", "@modelcontextprotocol/server-github"]); + expect(server.env).toEqual({ FOO: "bar", GITHUB_TOKEN: TOKEN_PLACEHOLDER }); + }); + + it("stdio: omits env when backend returned nothing", () => { + const qs: McpQuickStart = { + transport: "stdio", + serverName: "foo", + command: "npx", + }; + const server = JSON.parse(content(qs, "json")).mcpServers.foo; + expect("env" in server).toBe(false); + }); + + it("stdio: omits env when backend returned an empty map", () => { + const qs: McpQuickStart = { + transport: "stdio", + serverName: "foo", + command: "npx", + env: {}, + }; + const server = JSON.parse(content(qs, "json")).mcpServers.foo; + expect("env" in server).toBe(false); + }); + + it("streamable-http: type=streamable_http, merges bearer + user headers, masks secret keys", () => { + const qs: McpQuickStart = { + transport: "streamable-http", + serverName: "github", + url: "https://mcp.example.com/github", + authType: "bearer", + headers: { "X-Trace": "web", "X-API-Key": "" }, + }; + const server = JSON.parse(content(qs, "json")).mcpServers.github; + expect(server.type).toBe("streamable_http"); + expect(server.url).toBe("https://mcp.example.com/github"); + expect(server.headers).toEqual({ + "X-Trace": "web", + "X-API-Key": TOKEN_PLACEHOLDER, + Authorization: `Bearer ${TOKEN_PLACEHOLDER}`, + }); + }); + + it("sse: type=sse", () => { + const qs: McpQuickStart = { + transport: "sse", + serverName: "foo", + url: "https://x/sse", + }; + const server = JSON.parse(content(qs, "json")).mcpServers.foo; + expect(server.type).toBe("sse"); + }); + + it("remote: omits headers when there are none and no bearer", () => { + const qs: McpQuickStart = { + transport: "streamable-http", + serverName: "foo", + url: "https://x", + authType: "none", + }; + const server = JSON.parse(content(qs, "json")).mcpServers.foo; + expect("headers" in server).toBe(false); + }); + + it("json key: slugifies a Chinese display name to an ASCII slug", () => { + const qs: McpQuickStart = { + transport: "streamable-http", + serverName: "获取天气 MCP", + url: "https://x", + authType: "none", + }; + const keys = Object.keys(JSON.parse(content(qs, "json")).mcpServers); + // Chinese chars are dropped; only the ASCII token survives. + expect(keys).toEqual(["mcp"]); + }); + + it("json key: falls back to mcp-server when the name has no ASCII chars", () => { + const qs: McpQuickStart = { + transport: "streamable-http", + serverName: "获取天气", + url: "https://x", + authType: "none", + }; + const keys = Object.keys(JSON.parse(content(qs, "json")).mcpServers); + expect(keys).toEqual(["mcp-server"]); + }); + + it("json key: an explicit slug overrides the derived one", () => { + const qs: McpQuickStart = { + transport: "streamable-http", + serverName: "获取天气 MCP", + slug: "weather", + url: "https://x", + authType: "none", + }; + const keys = Object.keys(JSON.parse(content(qs, "json")).mcpServers); + expect(keys).toEqual(["weather"]); + }); + + it("json key: sanitizes a dirty manual slug (Chinese/upper/space/underscore)", () => { + const qs: McpQuickStart = { + transport: "streamable-http", + serverName: "获取天气 MCP", + slug: "My Weather_服务 MCP", + url: "https://x", + authType: "none", + }; + const keys = Object.keys(JSON.parse(content(qs, "json")).mcpServers); + expect(keys).toEqual(["my-weather-mcp"]); + }); + + it("json key: falls back to safe default when a manual slug slugifies to empty", () => { + const qs: McpQuickStart = { + transport: "streamable-http", + serverName: "获取天气 MCP", + slug: "服务器", + url: "https://x", + authType: "none", + }; + const keys = Object.keys(JSON.parse(content(qs, "json")).mcpServers); + expect(keys).toEqual(["mcp-server"]); + }); +}); + +describe("buildQuickStartTabs — prompt", () => { + it("stdio: renders non-secret env as-is, secret env as placeholder", () => { + const qs: McpQuickStart = { + transport: "stdio", + serverName: "github", + command: "npx", + args: ["-y", "@x/y"], + env: { FOO: "bar", GITHUB_TOKEN: "" }, + }; + const prompt = content(qs, "prompt"); + expect(prompt).toContain("FOO=bar"); + expect(prompt).toContain(`GITHUB_TOKEN=${TOKEN_PLACEHOLDER}`); + }); + + it("stdio: skips the env line entirely when env map is empty", () => { + const qs: McpQuickStart = { + transport: "stdio", + serverName: "foo", + command: "npx", + env: {}, + }; + expect(content(qs, "prompt")).not.toContain("环境变量"); + }); + + it("remote: renders bearer + user headers, masks secret KEYs", () => { + const qs: McpQuickStart = { + transport: "streamable-http", + serverName: "foo", + url: "https://x", + authType: "bearer", + headers: { "X-Trace": "web", "X-API-Key": "" }, + }; + const prompt = content(qs, "prompt"); + expect(prompt).toContain("X-Trace: web"); + expect(prompt).toContain(`X-API-Key: ${TOKEN_PLACEHOLDER}`); + expect(prompt).toContain(`Bearer ${TOKEN_PLACEHOLDER}`); + }); + + it("remote: skips 请求头 line when no headers and no bearer", () => { + const qs: McpQuickStart = { + transport: "streamable-http", + serverName: "foo", + url: "https://x", + authType: "none", + }; + const prompt = content(qs, "prompt"); + expect(prompt).not.toContain("请求头"); + expect(prompt).not.toContain("鉴权"); + }); +}); diff --git a/packages/dmworkmcp/src/api/quickStartTemplates.ts b/packages/dmworkmcp/src/api/quickStartTemplates.ts new file mode 100644 index 000000000..a4793d0f0 --- /dev/null +++ b/packages/dmworkmcp/src/api/quickStartTemplates.ts @@ -0,0 +1,235 @@ +import { isSecretKey, slugifyServerName } from "../utils/constants"; +import type { McpQuickStart } from "../types/mcp"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Quick-start template generation +// ═══════════════════════════════════════════════════════════════════════════ +// The two quick-access tabs (提示词 / JSON) are ALL generated from a single +// structured `quickStart` payload plus the client-agnostic templates below. +// No MCP ships hand-written snippets. Per the LSC-71 conclusion: +// - default tab = 提示词 (natural-language instruction for agent clients) +// - JSON = `mcpServers` snippet — Cursor / Claude Desktop shape: +// stdio → { command, args, env } (NO `type` field) +// remote → { type: "streamable_http" | "sse", url, headers } +// Claude Code also accepts `type: "stdio"`, but Cursor / Claude Desktop / +// Codex etc. don't — omitting it keeps one snippet copy-pasteable across +// the whole ecosystem, which is what users actually do. +// - the token position always renders as the placeholder below (never a real +// token, never pre-filled) +// ═══════════════════════════════════════════════════════════════════════════ + +/** The visible token placeholder. Never pre-fill a real token. */ +export const TOKEN_PLACEHOLDER = "<把这里换成你的 Token>"; + +export type QuickStartTabKey = "prompt" | "json"; + +export interface QuickStartTab { + key: QuickStartTabKey; + /** i18n key suffix under `mcp.detail.qsTab`. */ + labelKey: string; + /** The generated, copy-ready text. */ + content: string; + /** Language hint for the code block styling. */ + lang: "text" | "bash" | "json"; +} + +/** Whether the transport is a remote (network) one. */ +function isRemote(qs: McpQuickStart): boolean { + return qs.transport === "streamable-http" || qs.transport === "sse"; +} + +/** + * The `type` value emitted for remote transports. `.mcp.json` (Claude Code) + * requires it; Cursor / Claude Desktop tolerate it. stdio gets no `type` at all + * (Cursor / Claude Desktop reject unknown fields on stdio; Claude Code accepts + * the omission). streamable-http emits the canonical `streamable_http` value + * (the ecosystem's own key), not the shorthand `http`. + */ +function jsonTypeField(qs: McpQuickStart): "sse" | "streamable_http" | null { + if (qs.transport === "sse") return "sse"; + if (qs.transport === "streamable-http") return "streamable_http"; + return null; +} + +/** The `mcpServers` JSON key — an ASCII slug, never the Chinese display name. + * A manually-supplied slug is run through the same slugify as the auto one, so + * Chinese / uppercase / spaces / underscores can never leak into the JSON key. */ +function serverKey(qs: McpQuickStart): string { + const source = qs.slug?.trim() ? qs.slug : qs.serverName; + return slugifyServerName(source); +} + +/** Build the JSON `mcpServers` snippet — Cursor / Claude Desktop shape. */ +function buildJson(qs: McpQuickStart): string { + const key = serverKey(qs); + if (isRemote(qs)) { + // Bearer token overrides any user-supplied Authorization header: we set + // it AFTER spreading the user headers, so `merged.Authorization` is the + // masked bearer line regardless of what came in. yujiawei PR#851 P2: + // the previous comment claimed user headers won on collision, which the + // code contradicts. + const merged: Record = maskSecrets(qs.headers ?? {}); + if (qs.authType === "bearer") { + merged.Authorization = `Bearer ${TOKEN_PLACEHOLDER}`; + } + const server: Record = { + type: jsonTypeField(qs), + url: qs.url ?? "", + }; + if (Object.keys(merged).length > 0) { + server.headers = merged; + } + return JSON.stringify({ mcpServers: { [key]: server } }, null, 2); + } + // stdio — no `type` field per Cursor / Claude Desktop convention. + const server: Record = { + command: qs.command ?? "npx", + args: qs.args ?? [], + }; + if (qs.env && Object.keys(qs.env).length > 0) { + server.env = maskSecrets(qs.env); + } + return JSON.stringify({ mcpServers: { [key]: server } }, null, 2); +} + +/** Replace secret-looking values with the token placeholder so the snippet is + * copy-pasteable without leaking anything the user's browser echoed back. Uses + * the same key pattern as the backend redaction rule (mcp-v1.md §5.1) so the + * frontend's "this is a secret" judgement matches the wire. */ +function maskSecrets(m: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(m)) { + out[k] = isSecretKey(k) ? TOKEN_PLACEHOLDER : v; + } + return out; +} + +/** + * Bilingual prompt templates for the copy-ready agent prompt. Kept inline + * (rather than sourced through `@octo/base` `t()`) so this module stays a + * pure computation with no React / i18n runtime dependency — that keeps + * the unit tests fast and free of the Semi-UI transform chain. + * + * Locale detection is a simple browser check: anything that starts with + * `zh` renders the Chinese prompt; everything else falls back to English. + * Consumers on non-browser runtimes (tests) will render the English copy, + * which is the safer default for cross-locale agents. + */ +type PromptTexts = { + remote: (v: { + serverName: string; + transport: string; + url: string; + extraHeaders: string; + auth: string; + }) => string; + stdio: (v: { + serverName: string; + command: string; + args: string; + env: string; + }) => string; + authBearer: (token: string) => string; + headersLabel: string; + envLabel: string; +}; + +const PROMPT_TEXTS: Record<"zh" | "en", PromptTexts> = { + zh: { + remote: ({ serverName, transport, url, extraHeaders, auth }) => + `帮我接入一个 MCP server: +- 名称:${serverName} +- 传输方式:${transport} +- 地址:${url}${extraHeaders}${auth} +请把它加到我的 MCP 配置里并确认连接可用。`, + stdio: ({ serverName, command, args, env }) => + `帮我接入一个本地(stdio)MCP server: +- 名称:${serverName} +- 启动命令:${command} ${args}${env} +请把它加到我的 MCP 配置里并确认连接可用。`, + authBearer: (token) => `\n鉴权:请求头 Authorization: Bearer ${token}`, + headersLabel: "\n请求头:", + envLabel: "\n环境变量:", + }, + en: { + remote: ({ serverName, transport, url, extraHeaders, auth }) => + `Please help me add an MCP server: +- Name: ${serverName} +- Transport: ${transport} +- URL: ${url}${extraHeaders}${auth} +Add it to my MCP config and confirm the connection works.`, + stdio: ({ serverName, command, args, env }) => + `Please help me add a local (stdio) MCP server: +- Name: ${serverName} +- Command: ${command} ${args}${env} +Add it to my MCP config and confirm the connection works.`, + authBearer: (token) => `\nAuth: header Authorization: Bearer ${token}`, + headersLabel: "\nHeaders: ", + envLabel: "\nEnv: ", + }, +}; + +function currentPromptLocale(): "zh" | "en" { + if (typeof navigator === "undefined") return "en"; + const lang = (navigator.language || "").toLowerCase(); + return lang.startsWith("zh") ? "zh" : "en"; +} + +/** Build the natural-language prompt for agent clients. Bilingual — the + * UI locale picks between the zh and en template above (Nit#2 on PR#851 + * addressed the pre-#894 hardcoded-Chinese case). */ +function buildPrompt(qs: McpQuickStart): string { + const texts = PROMPT_TEXTS[currentPromptLocale()]; + if (isRemote(qs)) { + const auth = + qs.authType === "bearer" ? texts.authBearer(TOKEN_PLACEHOLDER) : ""; + const extraHeaders = + qs.headers && Object.keys(qs.headers).length > 0 + ? texts.headersLabel + + Object.entries(qs.headers) + .map(([k, v]) => + isSecretKey(k) ? `${k}: ${TOKEN_PLACEHOLDER}` : `${k}: ${v}` + ) + .join(", ") + : ""; + return texts.remote({ + serverName: qs.serverName, + transport: qs.transport, + url: qs.url ?? "", + extraHeaders, + auth, + }); + } + const args = (qs.args ?? []).join(" "); + const env = + qs.env && Object.keys(qs.env).length > 0 + ? texts.envLabel + + Object.entries(qs.env) + .map(([k, v]) => + isSecretKey(k) ? `${k}=${TOKEN_PLACEHOLDER}` : `${k}=${v}` + ) + .join(", ") + : ""; + return texts.stdio({ + serverName: qs.serverName, + command: qs.command ?? "npx", + args, + env, + }); +} + +/** + * Generate the two copy-ready tabs from the structured quick-start payload. + * Order matters: 提示词 first (the default tab). + */ +export function buildQuickStartTabs(qs: McpQuickStart): QuickStartTab[] { + return [ + { + key: "prompt", + labelKey: "prompt", + content: buildPrompt(qs), + lang: "text", + }, + { key: "json", labelKey: "json", content: buildJson(qs), lang: "json" }, + ]; +} diff --git a/packages/dmworkmcp/src/components/MarketSidebar.tsx b/packages/dmworkmcp/src/components/MarketSidebar.tsx new file mode 100644 index 000000000..e760995ac --- /dev/null +++ b/packages/dmworkmcp/src/components/MarketSidebar.tsx @@ -0,0 +1,149 @@ +import React, { Component } from "react"; +import { I18nContext, t, WKApp } from "@octo/base"; +import { SkillListPage } from "@dmwork/skillmarket"; +import McpMarketListPage from "../pages/McpMarketListPage"; + +interface MarketItem { + id: string; + routePath: string; + label: () => string; + render: () => React.ReactElement; +} + +// Order below controls the sidebar tab order. Keep MCP first — it's the +// original tenant of "/mcp-market" and the NavRail's onPress boots into it. +// Skills was folded in from the standalone /skill-market module (which now +// only registers i18n + this page) so users see a single "市场" entry with +// two tabs, not two navrail icons. +const MARKET_ITEMS: MarketItem[] = [ + { + id: "mcp", + routePath: "/mcp-market/mcp", + label: () => t("mcp.sidebar.mcp"), + render: () => , + }, + { + id: "skills", + routePath: "/mcp-market/skills", + label: () => t("mcp.sidebar.skills"), + render: () => , + }, +]; + +interface MarketSidebarState { + activeId: string; +} + +function findMarketItemByRoutePath(path?: string): MarketItem | undefined { + if (!path) return undefined; + return MARKET_ITEMS.find((item) => item.routePath === path); +} + +/** + * "Markets" sidebar rendered in WKLayout.contentLeft when the mcp-market + * NavRail entry is active. Users click items to switch which market page + * is mounted in WKLayout.contentRight (via WKApp.routeRight.replaceToRoot). + * + * The initial right-pane content is pushed by the NavRail menu's onPress + * (see module.tsx) — this component only reacts to sidebar clicks, so we + * don't double-mount the page on activation. activeId is seeded to the + * first item to match that initial push. + */ +export default class MarketSidebar extends Component<{}, MarketSidebarState> { + static contextType = I18nContext; + declare context: React.ContextType; + + state: MarketSidebarState = { + activeId: + findMarketItemByRoutePath(WKApp.route.currentPath)?.id ?? + findMarketItemByRoutePath(window.location.pathname)?.id ?? + MARKET_ITEMS[0].id, + }; + + componentDidMount() { + WKApp.mittBus.on("space-changed", this.handleSpaceChanged); + WKApp.mittBus.on("wk:nav-menu-activated", this.handleNavMenuActivated); + if (WKApp.currentMenuId === "mcp-market") { + this.replaceRightPane(this.currentItem()); + } + } + + componentWillUnmount() { + WKApp.mittBus.off("space-changed", this.handleSpaceChanged); + WKApp.mittBus.off("wk:nav-menu-activated", this.handleNavMenuActivated); + } + + private currentItem = () => { + return ( + findMarketItemByRoutePath(WKApp.route.currentPath) ?? + findMarketItemByRoutePath(window.location.pathname) ?? + MARKET_ITEMS.find((item) => item.id === this.state.activeId) ?? + MARKET_ITEMS[0] + ); + }; + + private replaceRightPane = (item: MarketItem) => { + try { + WKApp.routeRight.replaceToRoot(item.render()); + } catch { + window.setTimeout(() => { + try { + WKApp.routeRight.replaceToRoot(item.render()); + } catch (retryError) { + console.error("[mcp-market] failed to mount right pane", retryError); + } + }, 0); + } + }; + + private handleClick = (item: MarketItem) => { + if (item.id !== this.state.activeId) { + this.setState({ activeId: item.id }); + } + this.replaceRightPane(item); + // Sync the URL so refresh/copy-link/back button land on this tab + // rather than whatever stale path was in the address bar before. + WKApp.route.syncPath(item.routePath); + }; + + private handleSpaceChanged = () => { + if (WKApp.currentMenuId !== "mcp-market") return; + this.replaceRightPane(this.currentItem()); + }; + + private handleNavMenuActivated = ({ menuId }: { menuId: string }) => { + if (menuId !== "mcp-market") return; + const item = this.currentItem(); + if (item.id !== this.state.activeId) { + this.setState({ activeId: item.id }); + } + }; + + render() { + const { activeId } = this.state; + return ( +
    +
    + {t("mcp.sidebar.header")} +
    +
      + {MARKET_ITEMS.map((item) => ( +
    • + +
    • + ))} +
    +
    + ); + } +} diff --git a/packages/dmworkmcp/src/components/McpCard.tsx b/packages/dmworkmcp/src/components/McpCard.tsx new file mode 100644 index 000000000..7d5754268 --- /dev/null +++ b/packages/dmworkmcp/src/components/McpCard.tsx @@ -0,0 +1,188 @@ +import React from "react"; +import { Tooltip } from "@douyinfe/semi-ui"; +import type { McpListItem } from "../types/mcp"; +import { t } from "@octo/base"; +import { IconGlyph } from "../utils/icon"; + +interface McpCardProps { + item: McpListItem; + onClick: (item: McpListItem) => void; + keyword?: string; +} + +export function Highlight({ text, keyword = "" }: { text: string; keyword?: string }) { + const index = text.toLowerCase().indexOf(keyword.trim().toLowerCase()); + if (!keyword.trim() || index < 0) return <>{text}; + return <>{text.slice(0, index)}{text.slice(index, index + keyword.trim().length)}{text.slice(index + keyword.trim().length)}; +} + +export function parseMatchReason(reason: string): { key: string; value?: string } { + const colon = reason.indexOf(":"); + const type = colon < 0 ? reason : reason.slice(0, colon); + const value = colon < 0 ? undefined : reason.slice(colon + 1); + const keys: Record = { name: "name", description: "description", category: "category", usage_example: "usage", tool: "tool", tag: "tag", creator: "creator" }; + return { key: `mcp.card.matchReason.${keys[type] ?? "other"}`, value }; +} + +export function MatchReasons({ reasons, keyword = "" }: { reasons: string[]; keyword?: string }) { + const revealing = reasons.filter((reason) => { + const type = reason.split(":", 1)[0]; + return type === "tool" || type === "usage_example" || type === "creator"; + }); + if (!revealing.length) return null; + return ( +
    + {revealing.map((reason) => { + const parsed = parseMatchReason(reason); + const value = parsed.value || keyword; + return ( + + {t(parsed.key)} + {value ? : null} + + ); + })} +
    + ); +} + +/** How many tags the card renders before collapsing the rest into a `+N` + * chip. Product decision: 3 keeps the tag row on a single line for typical + * cases while still surfacing the most-relevant tags on a real record. */ +const CARD_TAG_LIMIT = 3; + +/** A single MCP server card in the list grid. */ +const McpCard: React.FC = ({ item, onClick, keyword }) => { + const visibleTags = item.tags.slice(0, CARD_TAG_LIMIT); + const overflowTags = item.tags.slice(CARD_TAG_LIMIT); + return ( +
    onClick(item)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(item); + } + }} + > +
    +
    + +
    +
    +
    + {/* Wrap the highlighted text in its own span so the ellipsis + clamp only applies to the name — not the source chip that + sits alongside it. `title` on the wrapper is the plain + browser tooltip (fine here: users deliberately hover for + a long name, and clicking still opens the detail modal + with the full name in the header). */} + + + + {/* Cards get an icon-only chip — real estate is tight and the + name of the bot rarely helps disambiguation in a grid. Hover + exposes the full "由 X 的 Bot 创建" tooltip. */} + +
    +
    + {visibleTags.map((tag) => ( + + + + ))} + {overflowTags.length > 0 && ( + /* +N chip: hover reveals the truncated tags via Semi Tooltip + as a mini pill cloud — matches the visual language of the + card's own tags so it reads as "the rest of the tag row". + 100 ms delay so the reveal feels near-instant. Click still + bubbles up to the card's onClick — no separate detail path + for the +N. */ + + {overflowTags.map((tag) => ( + + {tag} + + ))} +
    + } + className="wk-mcp-tooltip-light" + mouseEnterDelay={100} + position="top" + > + + +{overflowTags.length} + + + )} +
    +
    +
    +
    + {item.matchReasons?.length ? : null} +
    + + {t("mcp.card.toolCount", { values: { count: item.toolCount } })} + +
    +
    + ); +}; + +/** + * Small "created by whom" chip for bot-authored MCPs (issue #894). Two shapes: + * - `icon-only` (card grid): just the 🤖 glyph; hover for the full tooltip + * naming the bot and its owner. Keeps the list compact. + * - `labeled` (detail modal): 🤖 + bot name, so the source is legible + * without a hover — the detail page has the room. + * Human/import/legacy rows never render a chip either way. + */ +export function SourceBadge({ + item, + variant = "labeled", +}: { + item: McpListItem; + variant?: "icon-only" | "labeled"; +}) { + if (item.createdByType !== "bot") return null; + const botName = item.createdByBotName || t("mcp.source.bot"); + const ownerHint = item.creatorName + ? t("mcp.source.botTooltip", { values: { owner: item.creatorName } }) + : ""; + // Same tooltip shape for both variants — the labeled chip clips long bot + // names with an ellipsis, so hover MUST reveal the full name (plus owner) + // no matter which variant the caller picks. + const tooltip = ownerHint ? `${botName} · ${ownerHint}` : botName; + const chip = ( + + + {variant === "labeled" && ( + {botName} + )} + + ); + // Semi UI Tooltip — near-instant reveal (100 ms) instead of the browser's + // sluggish 500-2000ms native title. Stopping propagation on the trigger + // wrapper is unnecessary: the tooltip layer sits above but the click + // bubble path still reaches the card, so clicking the chip still opens + // the detail like any other card area. + return ( + + {chip} + + ); +} + +export default McpCard; diff --git a/packages/dmworkmcp/src/components/McpCreateModal.tsx b/packages/dmworkmcp/src/components/McpCreateModal.tsx new file mode 100644 index 000000000..ff642e53d --- /dev/null +++ b/packages/dmworkmcp/src/components/McpCreateModal.tsx @@ -0,0 +1,1268 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { WKModal, WKInput, WKButton, t } from "@octo/base"; +import { Input, Select, TextArea, Toast } from "@douyinfe/semi-ui"; +import { + createMcp, + probeMcpTools, + isProbeAvailable, + updateMcp, + uploadMcpIcon, +} from "../api/mcpService"; +import { MCP_CATEGORY_LABELS, MCP_CATEGORY_ORDER } from "../mock/mcpMock"; +import { + applySecretSentinel, + SECRET_PLACEHOLDER_SENTINEL, + isSecretKey, + slugifyServerName, +} from "../utils/constants"; +import type { + CreateMcpParams, + McpDetail, + McpFaq, + McpProbeRequest, + McpTransport, + McpVisibility, +} from "../types/mcp"; +import { isImageIcon } from "../utils/icon"; + +interface McpCreateModalProps { + visible: boolean; + onClose: () => void; + /** Fires on both create and edit success. For an edit save, `updated` is + * the fresh detail from the server so the parent can patch the list in + * place (avoids scroll-reset from a full refetch). Create passes no arg + * because the new row's list position depends on the current sort/filter + * and is easiest to surface via a full reload. */ + onSaved: (updated?: McpDetail) => void; + /** When set, the modal becomes an EDIT modal: prefilled from `editing`, + * submits via updateMcp(id), and uses the edit title/label copy. Absent = + * create mode (original behavior). */ + editing?: McpDetail | null; +} + +const ICON_MAX_BYTES = 2 * 1024 * 1024; + +/** + * Per-field max input lengths. Kept in sync with the backend column limits so + * the client blocks over-long input before it ever reaches the wire (the + * `maxLength` attribute hard-caps the field; a hint tells the user why). + */ +const MAXLEN = { + name: 64, + slogan: 200, + url: 2048, + command: 256, + arg: 512, + headerKey: 128, + headerValue: 1024, + toolName: 64, + text: 500, // tool description / FAQ question+answer / note +} as const; + +const EMPTY: CreateMcpParams = { + name: "", + slug: "", + category: "dev", + icon: "", + tags: [], + slogan: "", + transport: "streamable-http", + url: "", + command: "", + args: [], + env: {}, + headers: {}, + authType: "none", + tools: [], + usageExamples: [], + faqs: [], + notes: [], + visibility: "public", +}; + +const TRANSPORT_OPTIONS: McpTransport[] = ["stdio", "streamable-http", "sse"]; + +function isRemote(transport: McpTransport): boolean { + return transport === "streamable-http" || transport === "sse"; +} + +function parseKV(raw: string, separator: "=" | ":"): Record { + const out: Record = {}; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const idx = trimmed.indexOf(separator); + if (idx === -1) continue; + const key = trimmed.slice(0, idx).trim(); + const val = trimmed.slice(idx + 1).trim(); + if (key) out[key] = val; + } + return out; +} + +/** Inject the ephemeral probe bearer as `Authorization: Bearer ` for + * the test-connect call only. The persisted `headers` map still submits the + * sentinel placeholder that mcp-v1.md §5.1 requires; this override lets the + * probe reach a real MCP server without persisting the real token. Any + * `Authorization` already in `headers` (e.g. the sentinel) is intentionally + * overwritten because the user's just-typed token is the more explicit + * signal. + */ +function mergeProbeBearer( + headers: Record, + authType: "bearer" | "none" | undefined, + probeBearer: string +): Record { + const token = probeBearer.trim(); + if (authType !== "bearer" || !token) return headers; + return { ...headers, Authorization: `Bearer ${token}` }; +} + +/** Convert a detail record to the flat create/update form shape. Preserves + * everything the wire carries; drops the redacted secret sentinel so the + * user sees empty inputs (submit re-applies the sentinel via + * applySecretSentinel, so a not-touched secret round-trips cleanly). */ +function detailToForm(detail: McpDetail): CreateMcpParams { + const qs = detail.quickStart; + return { + name: detail.name, + slug: qs.slug ?? "", + category: detail.category, + icon: detail.icon, + tags: detail.tags ?? [], + slogan: detail.slogan, + transport: qs.transport, + url: qs.url ?? "", + command: qs.command ?? "", + args: qs.args ?? [], + env: stripSecretSentinel(qs.env), + headers: stripSecretSentinel(qs.headers), + authType: qs.authType ?? "none", + tools: detail.tools, + usageExamples: detail.usageExamples, + faqs: detail.faqs, + notes: detail.notes, + visibility: detail.visibility ?? "public", + }; +} + +/** Replace the redacted sentinel with an empty string on secret-typed keys, + * so the user sees a blank input instead of the wire literal. Non-secret + * keys pass through untouched. */ +function stripSecretSentinel( + m: Record | undefined +): Record { + if (!m) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(m)) { + out[k] = isSecretKey(k) && v === SECRET_PLACEHOLDER_SENTINEL ? "" : v; + } + return out; +} + +/** Serialize a KV map back to the "KEY=VALUE" or "Header: value" text buffer + * used by the env/headers