feat(ui/mcp): redesign MCP connections page with componentized layout

This commit is contained in:
matevip 2026-05-14 14:09:44 +08:00
parent bf3f4af913
commit 0192a62f21
34 changed files with 2315 additions and 636 deletions

View File

@ -78,21 +78,26 @@ Same brain. Same memory. Same tools. Different doors.
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a pixel-art avatar, and a color of their own — five career templates ship ready (Product Researcher · Customer Support · Knowledge Curator · Data Analyst · Executive Assistant). **ReAct** drives iterative reasoning, **Plan-and-Execute** decomposes complex multi-step work, employees can delegate to one another in parallel. Dynamic context pruning, smart truncation, stale-stream cleanup — the boring stuff that makes long conversations actually work.
### Knowledge & memory
- **LLM Wiki** — raw materials digest into linked pages with citations; the **hot cache** auto-injects into every employee's system prompt
- **LLM Wiki** — raw materials digest into linked pages with citations; the **hot cache** auto-injects into every employee's system prompt. **Transformations engine** (1.3.0+) turns the Wiki from a search index into a processing pipeline
- **Workspace memory**`AGENTS.md`, `SOUL.md`, `PROFILE.md`, `MEMORY.md`, daily notes
- **Memory lifecycle** — post-conversation extraction, scheduled consolidation, Dreaming workflows
- **Memory lifecycle** — post-conversation extraction, scheduled consolidation, Dreaming workflows. Workflows can also write directly into an employee's `MEMORY.md` via the `write_memory` step
### Skills · MCP · ACP — three ways to extend capability
- **SKILL.md packages** — manifest + prompt + tool list + **LESSONS.md (gets smarter the more you use it)**. Eight starter templates plus a five-step creation wizard, with **Pre-flight checks** that tell you what's missing before install
- **MCP** — stdio / SSE / Streamable HTTP, plug into any external tool server
- **MCP** — stdio / SSE / Streamable HTTP, plug into any external tool server. **Per-employee binding** (1.3.0+) means a tool you install for one employee doesn't bleed into another's toolbox
- **ACP** — bring top-tier coding agents like Claude Code and Codex in as employees, auto-bridged to skill cards with wrapper tools
- **Tool Guard** — RBAC + approval flow + path protection. Capability needs boundaries
### Business orchestration (1.3.0+)
- **Workflow** — compose multiple employees plus system actions (approval / channel dispatch / write-memory) into a publishable, triggerable, replayable linear DSL. Seven step modes (`sequential` / `fan_out` / `collect` / `conditional` / `await_approval` / `dispatch_channel` / `write_memory`). JSON-first authoring with Monaco + schema validation, or natural-language → draft generation
- **Triggers** — wire system events to workflows or to employee conversations. Six pattern types (`cron` / `webhook` / `channel_message` / `agent_lifecycle` / `content_match` / `workflow_completion`). Default-on event governance: dedup, per-trigger rate limit, bot-self filter, recursion guard, fail-closed unknown patterns
- **Wiki Transformations** — Wiki stops being retrieval-only. User-authored templates run against raw materials or existing pages, with cross-material map-reduce aggregation, reverse-citation extraction, JSON output mode, and per-template model picker
### You see what every employee is doing
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, what step they're on, how many tokens, one-click force-recycle when stuck. Streaming is staged honestly (thinking / tool / answer), per-event SSE IDs make reconnects safe, multi-employee delegation no longer fights itself, long tasks demand evidence-grounded answers.
### Multimodal creation
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons.
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons. **Sidecar routing** (1.3.0+) means a text-only main model + an image attachment no longer dead-ends — a configured vision model describes the image, and the main model answers. **Image edit** lands too: refer to an earlier conversation attachment by `msg:<id>:<idx>` and ask the model to recolor or restyle it. Four **document-generation tools** (`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`) render Markdown straight to Office files inside the JVM — no subprocess, no Office install.
### Enterprise-ready
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema that auto-heals on upgrade. One JAR to ship. MySQL in production, H2 for dev — nothing to change in your code.
@ -192,7 +197,8 @@ Desktop binaries ship via [GitHub Releases](https://github.com/matevip/mateclaw/
|---|---|
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · LESSONS self-evolution |
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP) · ACP bridge (Claude Code / Codex) |
| Orchestration | Workflow (7 step modes · Pebble DSL) · Triggers (6 pattern types · event governance) · Wiki Transformations (1.3.0+) |
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP · per-agent binding) · ACP bridge (Claude Code / Codex) |
| Database | H2 (dev) · MySQL 8.0+ (prod) |
| Auth | Spring Security + JWT |
| Frontend | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
@ -207,7 +213,9 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc
## Roadmap
Sharper multi-employee collaboration · Smarter model routing · Deeper multimodal understanding · Longer-lived memory · A richer ClawHub · More ACP upstream integrations.
**v1.3.0 (shipped 2026-05-13)** — Workflow engine · 6-pattern trigger system · Wiki transformations · per-agent MCP binding · multimodal sidecar routing · four JVM-native document generation tools · image edit. See the [v1.3.0 release notes](https://claw.mate.vip/docs/en/releases/1.3.0) for the full story.
**Next** — Drag-to-edit workflow canvas · run replay timeline · `loop` and `invoke_skill` step modes · trigger priorities and event replay · industry scenario marketplace · more ACP upstream integrations.
## Contributing

View File

@ -78,21 +78,26 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**像素艺术头像、专属配色——5 个职业模板(产品研究员 · 客户支持 · 知识管理员 · 数据分析师 · 行政助理)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些"不起眼"的基础设施。
### 知识与记忆
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面;**热点缓存**自动注入到员工的 system prompt
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面;**热点缓存**自动注入到员工的 system prompt。**加工器引擎**1.3.0+)把 Wiki 从"搜索索引"升级为"处理流水线"
- **工作区记忆**`AGENTS.md` / `SOUL.md` / `PROFILE.md` / `MEMORY.md` / 每日笔记
- **记忆生命周期** — 对话后自动提取 · 定时整理 · Dreaming 工作流
- **记忆生命周期** — 对话后自动提取 · 定时整理 · Dreaming 工作流。工作流也可以通过 `write_memory` step 直接写进员工的 `MEMORY.md`
### 技能 · MCP · ACP — 三种"接外部能力"的方式
- **SKILL.md 技能包** — 一份 manifest + prompt + 工具列表 + **LESSONS.md用得越多越聪明**。8 个起步模板 + 5 步创作向导,安装前自动跑 **Pre-flight 检查**告诉你缺什么
- **MCP** — stdio / SSE / Streamable HTTP 三种传输,接入任意外部工具服务器
- **MCP** — stdio / SSE / Streamable HTTP 三种传输,接入任意外部工具服务器。**每位员工独立绑定**1.3.0+)——一位员工装的工具不会渗到其他人的工具栏里
- **ACP** — 把 Claude Code、Codex 这种顶级编码 Agent 以"员工"身份接入,桥接成技能卡 + 包装工具
- **Tool Guard** — RBAC + 审批流 + 文件路径保护。能力必须有边界
### 业务流程编排1.3.0+
- **工作流Workflow** — 把多位员工 + 系统动作(审批 / 渠道分发 / 写记忆)按线性 step DSL 编排成一条可发布、可触发、可重放的业务流程。7 种 step mode`sequential` / `fan_out` / `collect` / `conditional` / `await_approval` / `dispatch_channel` / `write_memory`。JSON-first 编辑Monaco + JSON schema + Pebble 静态检查),或者用一句话生成草稿
- **触发器Trigger** — 把"系统里发生的事"自动接到工作流或员工对话上。6 种 pattern type`cron` / `webhook` / `channel_message` / `agent_lifecycle` / `content_match` / `workflow_completion`。事件治理默认开去重、per-trigger 限速、bot 自循环过滤、A→B→A 递归保护、未知 pattern fail-closed
- **Wiki 加工器** — Wiki 不再只是被动检索。用户自定义模板对原料或现有页面跑模板,跨原料 map-reduce 聚合reverse-citation 绑定到源 chunkJSON 输出 + 可选 JSON Schema每个模板独立选模型
### 你看得见每位员工正在干什么
**Admin 运行时控制台**`后台 → 系统 → 运行时`)——谁在跑、跑到哪一步、占多少 token、卡住了一键回收。流式分阶段显示思考 / 工具 / 回答SSE 每事件 ID 支持安全重连,多员工协作不打架,长任务必须有真实证据才回答。
### 多模态创作
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民不是附加插件。
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民不是附加插件。**多模态旁路**1.3.0+)让纯文本主模型遇到图片附件时自动调用配置好的视觉模型转描述,主对话保持便宜。**图像编辑**也到位:用 `msg:<id>:<idx>` 引用会话里更早的某张图,让模型改色、改风格。**4 个文档生成工具**`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`)在 JVM 内把 Markdown 直接渲染成 Office 文件——不 fork 子进程、不依赖 npm、不需要装 Office。
### 企业就绪
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**多实例不双发。完整审计事件流。Flyway 管理数据库 schema升级时自愈。一个 JAR 交付。生产用 MySQL开发用 H2代码零改动。
@ -192,7 +197,8 @@ mateclaw/
|---|---|
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · LESSONS 自我进化 |
| 能力扩展 | SKILL.md 包 · MCP 协议stdio / SSE / HTTP· ACP 桥接Claude Code / Codex |
| 业务编排 | 工作流7 step mode · Pebble DSL· 触发器6 pattern type · 事件治理)· Wiki 加工器1.3.0+|
| 能力扩展 | SKILL.md 包 · MCPstdio / SSE / HTTP · per-agent 绑定)· ACP 桥接Claude Code / Codex |
| 数据库 | H2开发· MySQL 8.0+(生产)|
| 认证 | Spring Security + JWT |
| 前端 | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
@ -207,7 +213,9 @@ mateclaw/
## 路线图
更强的多员工协作 · 更智能的模型路由 · 更深度的多模态理解 · 更长久的记忆 · 更繁荣的 ClawHub · 更多 ACP 上游集成。
**v1.3.02026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。完整故事见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。
**下一步** — 工作流画布可拖拉编辑 · 运行回放时间线 · `loop` / `invoke_skill` step mode · 触发器优先级 + 事件回放 · 行业场景应用市场 · 更多 ACP 上游集成。
## 参与贡献

View File

@ -55,7 +55,7 @@
<text x="140" y="264" text-anchor="middle" font-size="10" fill="#665245">LLM Wiki KB</text>
<text x="140" y="282" text-anchor="middle" font-size="10" fill="#665245">Structured + Backlinks</text>
<text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">Citations + Soft Archive</text>
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">Lazy ingest · On-demand</text>
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">+ Transformations (1.3.0+)</text>
<text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">A library, not a vector store</text>
<line x1="240" y1="280" x2="408" y2="280" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="406,275 414,280 406,285" fill="#184a45" opacity="0.6"/>
@ -98,13 +98,19 @@
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
<!-- ===== Orchestration tier (NEW 1.3.0): Workflow + Trigger ===== -->
<rect x="350" y="358" width="260" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">Orchestration · Workflow + Trigger</text>
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">Events → multi-employee → approval / dispatch / memory</text>
<!-- ===== Bottom Center: Provider Pool + Failover ===== -->
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>
<text x="480" y="442" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">Provider Pool · Failover</text>
<text x="480" y="460" text-anchor="middle" font-size="10" fill="#dce8e4">Cloud + Local · 14+ providers</text>
<text x="480" y="475" text-anchor="middle" font-size="9" fill="#dce8e4">Health Tracker · Auto-switch</text>
<line x1="480" y1="420" x2="480" y2="352" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="475,354 480,346 485,354" fill="#184a45" opacity="0.6"/>
<!-- Arrow up to agent (short, stops before orchestration band) -->
<line x1="480" y1="420" x2="480" y2="398" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="475,400 480,392 485,400" fill="#184a45" opacity="0.6"/>
<rect x="340" y="530" width="280" height="26" rx="13" fill="url(#primary)"/>
<text x="480" y="548" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">Mate is companion. Claw is capability.</text>

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -57,7 +57,7 @@
<text x="140" y="264" text-anchor="middle" font-size="10" fill="#665245">LLM Wiki 知识库</text>
<text x="140" y="282" text-anchor="middle" font-size="10" fill="#665245">结构化消化 + 双向链接</text>
<text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">引用溯源 + 软归档</text>
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">lazy 入库 · 按需出页</text>
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">+ 加工器流水线1.3.0+</text>
<text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">是图书馆,不是向量库</text>
<!-- Arrow right -->
<line x1="240" y1="280" x2="408" y2="280" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
@ -105,14 +105,19 @@
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
<!-- ===== Orchestration tier (NEW 1.3.0): Workflow + Trigger ===== -->
<rect x="350" y="358" width="260" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">业务编排 · 工作流 + 触发器</text>
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">事件触发 → 多员工协作 → 审批 / 分发 / 写记忆</text>
<!-- ===== Bottom Center: Models + Failover ===== -->
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>
<text x="480" y="442" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">模型池 · Failover</text>
<text x="480" y="460" text-anchor="middle" font-size="10" fill="#dce8e4">云端 + 本地 · 14+ 供应商</text>
<text x="480" y="475" text-anchor="middle" font-size="9" fill="#dce8e4">健康追踪 · 自动切换</text>
<!-- Arrow up to agent -->
<line x1="480" y1="420" x2="480" y2="352" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="475,354 480,346 485,354" fill="#184a45" opacity="0.6"/>
<!-- Arrow up to agent (short, stops before orchestration band) -->
<line x1="480" y1="420" x2="480" y2="398" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="475,400 480,392 485,400" fill="#184a45" opacity="0.6"/>
<!-- Footer -->
<rect x="340" y="530" width="280" height="26" rx="13" fill="url(#primary)"/>

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -74,15 +74,15 @@
<g transform="translate(56, 222)">
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">ReAct Agent</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Think Act Observe</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Iterative Reasoning Loop</text>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Reasoning Engines</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">ReAct · Think→Act→Observe</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · Decompose</text>
</g>
<g transform="translate(244, 222)">
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Plan-Execute</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Decompose → Step Execute</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Complex Task Orchestration</text>
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Workflow + Trigger</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">7 step modes · 6 patterns</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">Business orchestration (1.3.0+)</text>
</g>
<g transform="translate(432, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
@ -99,8 +99,8 @@
<g transform="translate(808, 222)">
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Knowledge</text>
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">Digestion</text>
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Knowledge digest</text>
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ Transforms (1.3)</text>
</g>
<!-- ===== Layer 3: Core Services ===== -->
@ -178,9 +178,9 @@
</g>
<g transform="translate(632, 488)">
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron → Channel</text>
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">Ambient AI</text>
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Proactive Delivery</text>
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron · Event Bus</text>
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">ShedLock distributed</text>
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Ambient AI · Proactive</text>
</g>
<g transform="translate(776, 488)">
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -77,15 +77,15 @@
<g transform="translate(56, 222)">
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">ReAct Agent</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">思考 行动 观察</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">循环推理引擎</text>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">推理双引擎</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">ReAct · 思考→行动→观察</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · 计划分解</text>
</g>
<g transform="translate(244, 222)">
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Plan-Execute</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">计划分解 → 逐步执行</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">复杂任务编排</text>
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">工作流 + 触发器</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">7 step mode · 6 pattern</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">业务流程编排1.3.0+</text>
</g>
<g transform="translate(432, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
@ -103,7 +103,7 @@
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">知识消化</text>
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">结构化页面</text>
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ 加工器1.3.0</text>
</g>
<!-- ===== Layer 3: Core Services ===== -->
@ -181,9 +181,9 @@
</g>
<g transform="translate(632, 488)">
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron → 渠道</text>
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">Ambient AI</text>
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">主动交付</text>
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron · 事件总线</text>
<text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">ShedLock 分布式锁</text>
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">主动交付 · Ambient AI</text>
</g>
<g transform="translate(776, 488)">
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Atlassian</title><path d="M7.12 11.084a.683.683 0 00-1.16.126L.075 22.974a.703.703 0 00.63 1.018h8.19a.678.678 0 00.63-.39c1.767-3.65.696-9.203-2.406-12.52zM11.434.386a15.515 15.515 0 00-.906 15.317l3.95 7.9a.703.703 0 00.628.388h8.19a.703.703 0 00.63-1.017L12.63.38a.664.664 0 00-1.196.006z"/></svg>

After

Width:  |  Height:  |  Size: 378 B

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google Chrome</title><path d="M12 0C8.21 0 4.831 1.757 2.632 4.501l3.953 6.848A5.454 5.454 0 0 1 12 6.545h10.691A12 12 0 0 0 12 0zM1.931 5.47A11.943 11.943 0 0 0 0 12c0 6.012 4.42 10.991 10.189 11.864l3.953-6.847a5.45 5.45 0 0 1-6.865-2.29zm13.342 2.166a5.446 5.446 0 0 1 1.45 7.09l.002.001h-.002l-5.344 9.257c.206.01.413.016.621.016 6.627 0 12-5.373 12-12 0-1.54-.29-3.011-.818-4.364zM12 16.364a4.364 4.364 0 1 1 0-8.728 4.364 4.364 0 0 1 0 8.728Z"/></svg>

After

Width:  |  Height:  |  Size: 535 B

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Cloudflare</title><path d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,7 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="28" height="28" rx="4" fill="black" style="fill:black;fill-opacity:1;"/>
<path d="M10.5724 15.2565C10.5724 17.5025 9.6613 19.3778 8.17805 21.1047H11.6319L11.6319 22.7786H6.33459V21.1895C7.95557 19.3566 8.58065 17.8628 8.58065 15.2565L10.5724 15.2565Z" fill="white" style="fill:white;fill-opacity:1;"/>
<path d="M17.4276 15.2565C17.4276 17.5025 18.3387 19.3778 19.822 21.1047H16.3681V22.7786H21.6654V21.1895C20.0444 19.3566 19.4194 17.8628 19.4194 15.2565H17.4276Z" fill="white" style="fill:white;fill-opacity:1;"/>
<path d="M10.5724 12.7435C10.5724 10.4975 9.66131 8.62224 8.17807 6.89532L11.6319 6.89532V5.22137L6.33461 5.22137V6.81056C7.95558 8.64343 8.58066 10.1373 8.58066 12.7435L10.5724 12.7435Z" fill="white" style="fill:white;fill-opacity:1;"/>
<path d="M17.4276 12.7435C17.4276 10.4975 18.3387 8.62224 19.822 6.89532L16.3681 6.89532L16.3681 5.22138L21.6654 5.22138V6.81056C20.0444 8.64343 19.4194 10.1373 19.4194 12.7435H17.4276Z" fill="white" style="fill:white;fill-opacity:1;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Figma</title><path d="M15.852 8.981h-4.588V0h4.588c2.476 0 4.49 2.014 4.49 4.49s-2.014 4.491-4.49 4.491zM12.735 7.51h3.117c1.665 0 3.019-1.355 3.019-3.019s-1.355-3.019-3.019-3.019h-3.117V7.51zm0 1.471H8.148c-2.476 0-4.49-2.014-4.49-4.49S5.672 0 8.148 0h4.588v8.981zm-4.587-7.51c-1.665 0-3.019 1.355-3.019 3.019s1.354 3.02 3.019 3.02h3.117V1.471H8.148zm4.587 15.019H8.148c-2.476 0-4.49-2.014-4.49-4.49s2.014-4.49 4.49-4.49h4.588v8.98zM8.148 8.981c-1.665 0-3.019 1.355-3.019 3.019s1.355 3.019 3.019 3.019h3.117V8.981H8.148zM8.172 24c-2.489 0-4.515-2.014-4.515-4.49s2.014-4.49 4.49-4.49h4.588v4.441c0 2.503-2.047 4.539-4.563 4.539zm-.024-7.51a3.023 3.023 0 0 0-3.019 3.019c0 1.665 1.365 3.019 3.044 3.019 1.705 0 3.093-1.376 3.093-3.068v-2.97H8.148zm7.704 0h-.098c-2.476 0-4.49-2.014-4.49-4.49s2.014-4.49 4.49-4.49h.098c2.476 0 4.49 2.014 4.49 4.49s-2.014 4.49-4.49 4.49zm-.097-7.509c-1.665 0-3.019 1.355-3.019 3.019s1.355 3.019 3.019 3.019h.098c1.665 0 3.019-1.355 3.019-3.019s-1.355-3.019-3.019-3.019h-.098z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>

After

Width:  |  Height:  |  Size: 823 B

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Hugging Face</title><path d="M12.025 1.13c-5.77 0-10.449 4.647-10.449 10.378 0 1.112.178 2.181.503 3.185.064-.222.203-.444.416-.577a.96.96 0 0 1 .524-.15c.293 0 .584.124.84.284.278.173.48.408.71.694.226.282.458.611.684.951v-.014c.017-.324.106-.622.264-.874s.403-.487.762-.543c.3-.047.596.06.787.203s.31.313.4.467c.15.257.212.468.233.542.01.026.653 1.552 1.657 2.54.616.605 1.01 1.223 1.082 1.912.055.537-.096 1.059-.38 1.572.637.121 1.294.187 1.967.187.657 0 1.298-.063 1.921-.178-.287-.517-.44-1.041-.384-1.581.07-.69.465-1.307 1.081-1.913 1.004-.987 1.647-2.513 1.657-2.539.021-.074.083-.285.233-.542.09-.154.208-.323.4-.467a1.08 1.08 0 0 1 .787-.203c.359.056.604.29.762.543s.247.55.265.874v.015c.225-.34.457-.67.683-.952.23-.286.432-.52.71-.694.257-.16.547-.284.84-.285a.97.97 0 0 1 .524.151c.228.143.373.388.43.625l.006.04a10.3 10.3 0 0 0 .534-3.273c0-5.731-4.678-10.378-10.449-10.378M8.327 6.583a1.5 1.5 0 0 1 .713.174 1.487 1.487 0 0 1 .617 2.013c-.183.343-.762-.214-1.102-.094-.38.134-.532.914-.917.71a1.487 1.487 0 0 1 .69-2.803m7.486 0a1.487 1.487 0 0 1 .689 2.803c-.385.204-.536-.576-.916-.71-.34-.12-.92.437-1.103.094a1.487 1.487 0 0 1 .617-2.013 1.5 1.5 0 0 1 .713-.174m-10.68 1.55a.96.96 0 1 1 0 1.921.96.96 0 0 1 0-1.92m13.838 0a.96.96 0 1 1 0 1.92.96.96 0 0 1 0-1.92M8.489 11.458c.588.01 1.965 1.157 3.572 1.164 1.607-.007 2.984-1.155 3.572-1.164.196-.003.305.12.305.454 0 .886-.424 2.328-1.563 3.202-.22-.756-1.396-1.366-1.63-1.32q-.011.001-.02.006l-.044.026-.01.008-.03.024q-.018.017-.035.036l-.032.04a1 1 0 0 0-.058.09l-.014.025q-.049.088-.11.19a1 1 0 0 1-.083.116 1.2 1.2 0 0 1-.173.18q-.035.029-.075.058a1.3 1.3 0 0 1-.251-.243 1 1 0 0 1-.076-.107c-.124-.193-.177-.363-.337-.444-.034-.016-.104-.008-.2.022q-.094.03-.216.087-.06.028-.125.063l-.13.074q-.067.04-.136.086a3 3 0 0 0-.135.096 3 3 0 0 0-.26.219 2 2 0 0 0-.12.121 2 2 0 0 0-.106.128l-.002.002a2 2 0 0 0-.09.132l-.001.001a1.2 1.2 0 0 0-.105.212q-.013.036-.024.073c-1.139-.875-1.563-2.317-1.563-3.203 0-.334.109-.457.305-.454m.836 10.354c.824-1.19.766-2.082-.365-3.194-1.13-1.112-1.789-2.738-1.789-2.738s-.246-.945-.806-.858-.97 1.499.202 2.362c1.173.864-.233 1.45-.685.64-.45-.812-1.683-2.896-2.322-3.295s-1.089-.175-.938.647 2.822 2.813 2.562 3.244-1.176-.506-1.176-.506-2.866-2.567-3.49-1.898.473 1.23 2.037 2.16c1.564.932 1.686 1.178 1.464 1.53s-3.675-2.511-4-1.297c-.323 1.214 3.524 1.567 3.287 2.405-.238.839-2.71-1.587-3.216-.642-.506.946 3.49 2.056 3.522 2.064 1.29.33 4.568 1.028 5.713-.624m5.349 0c-.824-1.19-.766-2.082.365-3.194 1.13-1.112 1.789-2.738 1.789-2.738s.246-.945.806-.858.97 1.499-.202 2.362c-1.173.864.233 1.45.685.64.451-.812 1.683-2.896 2.322-3.295s1.089-.175.938.647-2.822 2.813-2.562 3.244 1.176-.506 1.176-.506 2.866-2.567 3.49-1.898-.473 1.23-2.037 2.16c-1.564.932-1.686 1.178-1.464 1.53s3.675-2.511 4-1.297c.323 1.214-3.524 1.567-3.287 2.405.238.839 2.71-1.587 3.216-.642.506.946-3.49 2.056-3.522 2.064-1.29.33-4.568 1.028-5.713-.624"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Linear</title><path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z"/></svg>

After

Width:  |  Height:  |  Size: 453 B

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Notion</title><path d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.981-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.841-.046.935-.56.935-1.167V6.354c0-.606-.233-.933-.748-.887l-15.177.887c-.56.047-.747.327-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.748 0-.935-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233 4.764 7.279v-6.44l-1.215-.139c-.093-.514.28-.887.747-.933zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.934.653.934 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.447-1.632z"/></svg>

After

Width:  |  Height:  |  Size: 978 B

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Playwright</title><path d="M23.996 7.462c-.056.837-.257 2.135-.716 3.85-.995 3.715-4.27 10.874-10.42 9.227-6.15-1.65-5.407-9.487-4.412-13.201.46-1.716.934-2.94 1.305-3.694.42-.853.846-.289 1.815.523.684.573 2.41 1.791 5.011 2.488 2.601.697 4.706.506 5.583.352 1.245-.219 1.897-.494 1.834.455Zm-9.807 3.863s-.127-1.819-1.773-2.286c-1.644-.467-2.613 1.04-2.613 1.04Zm4.058 4.539-7.769-2.172s.446 2.306 3.338 3.153c2.862.836 4.43-.98 4.43-.981Zm2.701-2.51s-.13-1.818-1.773-2.286c-1.644-.469-2.612 1.038-2.612 1.038ZM8.57 18.23c-4.749 1.279-7.261-4.224-8.021-7.08C.197 9.831.044 8.832.003 8.188c-.047-.73.455-.52 1.415-.354.677.118 2.3.261 4.308-.28a11.28 11.28 0 0 0 2.41-.956c-.058.197-.114.4-.17.61-.433 1.618-.827 4.055-.632 6.426-1.976.732-2.267 2.423-2.267 2.423l2.524-.715c.227 1.002.6 1.987 1.15 2.838a5.914 5.914 0 0 1-.171.049Zm-4.188-6.298c1.265-.333 1.363-1.631 1.363-1.631l-3.374.888s.745 1.076 2.01.743Z"/></svg>

After

Width:  |  Height:  |  Size: 1000 B

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>PostHog</title><path d="M9.854 14.5 5 9.647.854 5.5A.5.5 0 0 0 0 5.854V8.44a.5.5 0 0 0 .146.353L5 13.647l.147.146L9.854 18.5l.146.147v-.049c.065.03.134.049.207.049h2.586a.5.5 0 0 0 .353-.854L9.854 14.5zm0-5-4-4a.487.487 0 0 0-.409-.144.515.515 0 0 0-.356.21.493.493 0 0 0-.089.288V8.44a.5.5 0 0 0 .147.353l9 9a.5.5 0 0 0 .853-.354v-2.585a.5.5 0 0 0-.146-.354l-5-5zm1-4a.5.5 0 0 0-.854.354V8.44a.5.5 0 0 0 .147.353l4 4a.5.5 0 0 0 .853-.354V9.854a.5.5 0 0 0-.146-.354l-4-4zm12.647 11.515a3.863 3.863 0 0 1-2.232-1.1l-4.708-4.707a.5.5 0 0 0-.854.354v6.585a.5.5 0 0 0 .5.5H23.5a.5.5 0 0 0 .5-.5v-.6c0-.276-.225-.497-.499-.532zm-5.394.032a.8.8 0 1 1 0-1.6.8.8 0 0 1 0 1.6zM.854 15.5a.5.5 0 0 0-.854.354v2.293a.5.5 0 0 0 .5.5h2.293c.222 0 .39-.135.462-.309a.493.493 0 0 0-.109-.545L.854 15.501zM5 14.647.854 10.5a.5.5 0 0 0-.854.353v2.586a.5.5 0 0 0 .146.353L4.854 18.5l.146.147h2.793a.5.5 0 0 0 .353-.854L5 14.647z"/></svg>

After

Width:  |  Height:  |  Size: 996 B

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Sentry</title><path d="M13.91 2.505c-.873-1.448-2.972-1.448-3.844 0L6.904 7.92a15.478 15.478 0 0 1 8.53 12.811h-2.221A13.301 13.301 0 0 0 5.784 9.814l-2.926 5.06a7.65 7.65 0 0 1 4.435 5.848H2.194a.365.365 0 0 1-.298-.534l1.413-2.402a5.16 5.16 0 0 0-1.614-.913L.296 19.275a2.182 2.182 0 0 0 .812 2.999 2.24 2.24 0 0 0 1.086.288h6.983a9.322 9.322 0 0 0-3.845-8.318l1.11-1.922a11.47 11.47 0 0 1 4.95 10.24h5.915a17.242 17.242 0 0 0-7.885-15.28l2.244-3.845a.37.37 0 0 1 .504-.13c.255.14 9.75 16.708 9.928 16.9a.365.365 0 0 1-.327.543h-2.287c.029.612.029 1.223 0 1.831h2.297a2.206 2.206 0 0 0 1.922-3.31z"/></svg>

After

Width:  |  Height:  |  Size: 686 B

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Slack</title><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Stripe</title><path d="M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.594-7.305h.003z"/></svg>

After

Width:  |  Height:  |  Size: 588 B

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Supabase</title><path d="M11.9 1.036c-.015-.986-1.26-1.41-1.874-.637L.764 12.05C-.33 13.427.65 15.455 2.409 15.455h9.579l.113 7.51c.014.985 1.259 1.408 1.873.636l9.262-11.653c1.093-1.375.113-3.403-1.645-3.403h-9.642z"/></svg>

After

Width:  |  Height:  |  Size: 303 B

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Vercel</title><path d="m12 1.608 12 20.784H0Z"/></svg>

After

Width:  |  Height:  |  Size: 132 B

View File

@ -0,0 +1,189 @@
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { mcpApi } from '@/api/index'
import type { McpServer, McpServerForm, McpTestResult } from '@/views/mcp/types'
import { mcpCatalog, type McpCatalogEntry } from '@/views/mcp/catalog'
/**
* Single source of truth for the MCP connections page. Owns the installed
* server list, search/pagination state, and CRUD helpers the page and
* components stay thin presentational layers.
*/
export function useMcpServers() {
const { t } = useI18n()
const installed = ref<McpServer[]>([])
const isLoading = ref(false)
const isRefreshing = ref(false)
const testingId = ref<number | null>(null)
const testResult = ref<McpTestResult | null>(null)
const search = ref('')
const pageSize = ref(12)
const installedPage = ref(1)
const catalogPage = ref(1)
// Reset both pages whenever the search input changes — prevents pointing
// at a page index that no longer exists after filtering shrinks the list.
watch(search, () => {
installedPage.value = 1
catalogPage.value = 1
})
const lowerSearch = computed(() => search.value.trim().toLowerCase())
const filteredInstalled = computed<McpServer[]>(() => {
const q = lowerSearch.value
if (!q) return installed.value
return installed.value.filter(s =>
s.name.toLowerCase().includes(q) ||
(s.description ?? '').toLowerCase().includes(q),
)
})
// Don't recommend a catalog entry whose key collides with an existing
// installed server name — once added it belongs in the Added section.
const installedNames = computed(() => new Set(installed.value.map(s => s.name)))
const filteredCatalog = computed<McpCatalogEntry[]>(() => {
const q = lowerSearch.value
return mcpCatalog.filter(c => {
if (installedNames.value.has(c.key)) return false
if (!q) return true
return (
c.name.toLowerCase().includes(q) ||
c.description.toLowerCase().includes(q) ||
c.key.toLowerCase().includes(q)
)
})
})
function paginate<T>(list: T[], page: number, size: number): T[] {
const start = (page - 1) * size
return list.slice(start, start + size)
}
const pagedInstalled = computed(() =>
paginate(filteredInstalled.value, installedPage.value, pageSize.value),
)
const pagedCatalog = computed(() =>
paginate(filteredCatalog.value, catalogPage.value, pageSize.value),
)
let loadGeneration = 0
async function reload() {
const gen = ++loadGeneration
isLoading.value = true
try {
const res: any = await mcpApi.list()
if (gen === loadGeneration) {
installed.value = (res?.data ?? []) as McpServer[]
}
} catch {
if (gen === loadGeneration) ElMessage.error(t('mcp.messages.loadFailed'))
} finally {
if (gen === loadGeneration) isLoading.value = false
}
}
async function refreshAll() {
isRefreshing.value = true
try {
await mcpApi.refresh()
ElMessage.success(t('mcp.messages.refreshSuccess'))
await reload()
} catch (e: any) {
ElMessage.error(e?.message || t('mcp.messages.saveFailed'))
} finally {
isRefreshing.value = false
}
}
async function saveServer(form: McpServerForm, editing: McpServer | null): Promise<boolean> {
try {
if (editing) {
await mcpApi.update(editing.id, form)
ElMessage.success(t('mcp.messages.updateSuccess'))
} else {
await mcpApi.create(form)
ElMessage.success(t('mcp.messages.createSuccess'))
}
await reload()
return true
} catch (e: any) {
ElMessage.error(e?.message || t('mcp.messages.saveFailed'))
return false
}
}
async function removeServer(server: McpServer): Promise<boolean> {
try {
await mcpApi.delete(server.id)
ElMessage.success(t('mcp.messages.deleteSuccess'))
await reload()
return true
} catch (e: any) {
ElMessage.error(e?.message || t('mcp.messages.saveFailed'))
return false
}
}
async function toggleServer(server: McpServer) {
try {
await mcpApi.toggle(server.id, !server.enabled)
ElMessage.success(t('mcp.messages.toggleSuccess'))
await reload()
} catch (e: any) {
ElMessage.error(e?.message || t('mcp.messages.saveFailed'))
}
}
async function testServer(server: McpServer) {
testingId.value = server.id
testResult.value = null
try {
const res: any = await mcpApi.test(server.id)
testResult.value = res.data as McpTestResult
} catch (e: any) {
testResult.value = {
success: false,
message: e?.message || 'Unknown error',
toolCount: 0,
latencyMs: 0,
discoveredTools: [],
}
} finally {
testingId.value = null
// Auto-dismiss toast after 4s so it doesn't linger on screen.
setTimeout(() => {
testResult.value = null
}, 4000)
}
}
return {
// state
installed,
isLoading,
isRefreshing,
testingId,
testResult,
search,
pageSize,
installedPage,
catalogPage,
// derived
filteredInstalled,
filteredCatalog,
pagedInstalled,
pagedCatalog,
// actions
reload,
refreshAll,
saveServer,
removeServer,
toggleServer,
testServer,
}
}

View File

@ -1529,9 +1529,47 @@ export default {
mcp: {
kicker: 'Capability Bridge',
title: 'MCP Connections',
subtitle: 'Connect agents to external data sources and tools',
desc: 'Manage MCP (Model Context Protocol) server connections',
addServer: 'Add Connection',
addCustom: 'Custom MCP',
refreshAll: 'Refresh All',
searchPlaceholder: 'Search MCP servers…',
emptyMatch: 'No servers match your search',
catalogBadge: 'From catalog · {name}',
credentialHint: 'Replace placeholders {keys} before saving',
credentialHintRequired: 'Required credentials: {keys} (replace placeholders before saving)',
credentialHintOptional: 'Optional credentials: {keys} (fill in if you need those APIs)',
credentialHintBoth: 'Required: {required} · Optional: {optional}',
sections: {
added: 'Added',
recommended: 'Recommended',
countItems: '{n}',
},
card: {
docs: 'View docs',
add: 'Install',
toolCount: '{n} tools',
},
toggle: {
enable: 'Enable',
disable: 'Disable',
},
kv: {
envKey: 'KEY',
envValue: 'value',
headerKey: 'Header',
headerValue: 'value',
addEnv: 'Add env var',
addHeader: 'Add header',
required: 'Required',
optional: 'Optional',
},
time: {
justNow: 'just now',
minutesAgo: '{n}m ago',
hoursAgo: '{n}h ago',
},
columns: {
name: 'Name',
transport: 'Transport',
@ -1561,10 +1599,10 @@ export default {
description: 'Description',
transport: 'Transport',
url: 'URL',
headers: 'HTTP Headers (JSON)',
headers: 'HTTP Headers',
command: 'Command',
args: 'Arguments (JSON array)',
env: 'Environment Variables (JSON)',
env: 'Environment Variables',
cwd: 'Working Directory',
connectTimeout: 'Connect Timeout (seconds)',
readTimeout: 'Read Timeout (seconds)',

View File

@ -1427,9 +1427,47 @@ export default {
mcp: {
kicker: '能力桥接',
title: 'MCP 连接',
subtitle: '把 Agent 连到外部数据源和工具',
desc: '管理 MCP (Model Context Protocol) 服务器连接',
addServer: '添加连接',
addCustom: '自定义 MCP',
refreshAll: '全量刷新',
searchPlaceholder: '搜索 MCP 服务…',
emptyMatch: '没有匹配的 MCP 服务',
catalogBadge: '来自 Catalog · {name}',
credentialHint: '请替换占位符 {keys} 再保存',
credentialHintRequired: '必填凭据:{keys}(请替换占位符再保存)',
credentialHintOptional: '可选凭据:{keys}(如需调用相关 API 请填写)',
credentialHintBoth: '必填:{required} · 可选:{optional}',
sections: {
added: '已添加',
recommended: '推荐',
countItems: '{n} 个',
},
card: {
docs: '查看文档',
add: '安装',
toolCount: '{n} 个工具',
},
toggle: {
enable: '启用',
disable: '停用',
},
kv: {
envKey: 'KEY',
envValue: '值',
headerKey: 'Header',
headerValue: '值',
addEnv: '添加环境变量',
addHeader: '添加 Header',
required: '必填',
optional: '可选',
},
time: {
justNow: '刚刚',
minutesAgo: '{n} 分钟前',
hoursAgo: '{n} 小时前',
},
columns: {
name: '名称',
transport: '协议',
@ -1459,10 +1497,10 @@ export default {
description: '描述',
transport: '传输协议',
url: 'URL',
headers: 'HTTP Headers (JSON)',
headers: 'HTTP Headers',
command: '命令',
args: '参数 (JSON 数组)',
env: '环境变量 (JSON)',
env: '环境变量',
cwd: '工作目录',
connectTimeout: '连接超时 (秒)',
readTimeout: '读取超时 (秒)',

View File

@ -1,653 +1,351 @@
<template>
<div class="page-container">
<div class="page-shell">
<div class="page-header">
<div class="page-lead">
<div class="page-kicker">{{ t('mcp.kicker') }}</div>
<h1 class="page-title">{{ t('mcp.title') }}</h1>
<p class="page-desc">{{ t('mcp.desc') }}</p>
</div>
<div class="header-actions">
<button class="btn-secondary" @click="refreshAll" :disabled="refreshing">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
{{ t('mcp.refreshAll') }}
</button>
<button class="btn-primary" @click="openCreateModal">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('mcp.addServer') }}
</button>
</div>
<div class="settings-section mcp-section-page">
<div class="section-header">
<div>
<h2 class="section-title">{{ t('mcp.title') }}</h2>
<p class="section-desc">{{ t('mcp.subtitle') }}</p>
</div>
<!-- Table -->
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>{{ t('mcp.columns.name') }}</th>
<th>{{ t('mcp.columns.lastStatus') }}</th>
<th class="th-center">{{ t('mcp.columns.toolCount') }}</th>
<th class="th-center">{{ t('mcp.columns.enabled') }}</th>
<th>{{ t('mcp.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="server in servers" :key="server.id" class="data-row">
<td>
<div class="server-info">
<div class="server-icon" :class="'icon-' + (server.lastStatus || 'disconnected')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
</div>
<div>
<div class="server-name">{{ server.name }}</div>
<div class="server-meta">
<span class="transport-tag">{{ server.transport }}</span>
<span v-if="server.description" class="server-desc">{{ server.description }}</span>
</div>
</div>
</div>
</td>
<td>
<div class="status-row">
<span class="status-dot" :class="'dot-' + (server.lastStatus || 'disconnected')"></span>
<span class="status-label">{{ t('mcp.status.' + (server.lastStatus || 'disconnected')) }}</span>
<span v-if="server.lastConnectedTime" class="status-time">{{ formatRelativeTime(server.lastConnectedTime) }}</span>
</div>
<div v-if="server.lastError" class="status-error" :title="server.lastError">
{{ truncate(server.lastError, 36) }}
</div>
</td>
<td class="td-center">
<span class="count-pill">{{ server.toolCount || 0 }}</span>
</td>
<td class="td-center">
<label class="toggle">
<input type="checkbox" :checked="server.enabled" @change="toggleServer(server)" />
<span class="toggle-track"></span>
</label>
</td>
<td>
<div class="row-actions">
<button class="row-btn" @click="openDetailModal(server)" :title="t('common.view')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M2.05 12a9.94 9.94 0 0 1 19.9 0 9.94 9.94 0 0 1-19.9 0z"/>
</svg>
</button>
<button class="row-btn" @click="testConnection(server)" :disabled="testingId === server.id" :title="t('mcp.actions.test')">
<svg v-if="testingId !== server.id" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spin">
<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/>
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/>
<line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/>
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>
</svg>
</button>
<button class="row-btn" @click="openEditModal(server)" :title="t('common.edit')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="row-btn danger" @click="deleteServer(server)" :title="t('common.delete')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
</div>
</td>
</tr>
<tr v-if="servers.length === 0">
<td colspan="5" class="empty-row">
<div class="empty-state">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:.35">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
<p>{{ t('mcp.messages.empty') }}</p>
<p class="empty-sub">{{ t('mcp.messages.emptyDesc') }}</p>
</div>
</td>
</tr>
</tbody>
</table>
<div class="section-header__actions">
<button class="btn-secondary" :disabled="isRefreshing" @click="refreshAll">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
:class="{ spin: isRefreshing }"
>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
{{ t('mcp.refreshAll') }}
</button>
<button class="btn-primary" @click="openCreateModal">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{{ t('mcp.addCustom') }}
</button>
</div>
</div>
<!-- Detail Modal -->
<div v-if="detailServer" class="modal-overlay" @click.self="closeDetailModal">
<div class="modal modal-wide">
<div class="modal-header">
<h2>{{ detailServer.name }}</h2>
<button class="modal-close" @click="closeDetailModal">&times;</button>
</div>
<div class="modal-body detail-grid">
<div class="detail-item">
<div class="detail-label">{{ t('mcp.columns.transport') }}</div>
<div class="detail-value">{{ t('mcp.transport.' + detailServer.transport) }}</div>
</div>
<div class="detail-item">
<div class="detail-label">{{ t('mcp.columns.lastStatus') }}</div>
<div class="detail-value">{{ t('mcp.status.' + (detailServer.lastStatus || 'disconnected')) }}</div>
</div>
<div class="detail-item">
<div class="detail-label">{{ t('mcp.columns.toolCount') }}</div>
<div class="detail-value">{{ detailServer.toolCount || 0 }}</div>
</div>
<div class="detail-item">
<div class="detail-label">{{ t('mcp.fields.enabled') }}</div>
<div class="detail-value">{{ detailServer.enabled ? 'Enabled' : 'Disabled' }}</div>
</div>
<div class="detail-item detail-full" v-if="detailServer.transport === 'stdio'">
<div class="detail-label">{{ t('mcp.fields.command') }}</div>
<div class="detail-value detail-code">{{ detailServer.command || '-' }}</div>
<div class="detail-sub" v-if="detailServer.argsJson">{{ detailServer.argsJson }}</div>
</div>
<div class="detail-item detail-full" v-else>
<div class="detail-label">{{ t('mcp.fields.url') }}</div>
<div class="detail-value detail-code">{{ detailServer.url || '-' }}</div>
</div>
<div class="detail-item">
<div class="detail-label">{{ t('mcp.fields.connectTimeout') }}</div>
<div class="detail-value">{{ detailServer.connectTimeoutSeconds || 30 }}s</div>
</div>
<div class="detail-item">
<div class="detail-label">{{ t('mcp.fields.readTimeout') }}</div>
<div class="detail-value">{{ detailServer.readTimeoutSeconds || 30 }}s</div>
</div>
<div class="detail-item detail-full" v-if="detailServer.lastError">
<div class="detail-label">Error</div>
<div class="detail-value detail-code" style="color: var(--mc-danger)">{{ detailServer.lastError }}</div>
</div>
<div class="detail-item detail-full" v-if="detailServer.description">
<div class="detail-label">{{ t('mcp.fields.description') }}</div>
<div class="detail-value">{{ detailServer.description }}</div>
</div>
</div>
</div>
<div class="mcp-filter-bar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
v-model="search"
class="mcp-search-input"
type="search"
:placeholder="t('mcp.searchPlaceholder')"
/>
</div>
<!-- Toast -->
<McpEmptyState
v-if="!isLoading && installedTotal === 0 && !search"
kind="initial"
@add="openCreateModal"
/>
<section v-if="installedTotal > 0 || search" class="mcp-block">
<div class="mcp-block-head">
<div class="mcp-block-title">{{ t('mcp.sections.added') }}</div>
<div class="mcp-block-count">
{{ t('mcp.sections.countItems', { n: installedTotal }) }}
</div>
</div>
<McpEmptyState v-if="installedTotal === 0" kind="search" />
<div v-else class="mcp-grid">
<McpCard
v-for="server in pagedInstalled"
:key="server.id"
:server="server"
:testing="testingId === server.id"
@edit="openEditModal"
@test="testServer"
@toggle="toggleServer"
/>
</div>
<div v-if="installedTotal > pageSize" class="mcp-pager-row">
<McPagination
v-model:page="installedPage"
v-model:size="pageSize"
:total="installedTotal"
:sizes="[12, 24, 48]"
/>
</div>
</section>
<section v-if="catalogTotal > 0" class="mcp-block">
<div class="mcp-block-head">
<div class="mcp-block-title">{{ t('mcp.sections.recommended') }}</div>
<div class="mcp-block-count">
{{ t('mcp.sections.countItems', { n: catalogTotal }) }}
</div>
</div>
<div class="mcp-grid">
<McpCard
v-for="entry in pagedCatalog"
:key="entry.key"
:catalog-entry="entry"
@add="openFromCatalog"
@docs="openDocs"
/>
</div>
<div v-if="catalogTotal > pageSize" class="mcp-pager-row">
<McPagination
v-model:page="catalogPage"
v-model:size="pageSize"
:total="catalogTotal"
:sizes="[12, 24, 48]"
/>
</div>
</section>
<McpFormModal
v-model="modalVisible"
:editing="editingServer"
:prefill="catalogPrefill"
@save="onSave"
@delete="onDelete"
/>
<transition name="toast">
<div v-if="testResult" class="test-toast" :class="testResult.success ? 'toast-ok' : 'toast-fail'">
<strong>{{ testResult.success ? t('mcp.testResult.success') : t('mcp.testResult.failed') }}</strong>
<div
v-if="testResult"
class="test-toast"
:class="testResult.success ? 'toast-ok' : 'toast-fail'"
>
<strong>{{
testResult.success
? t('mcp.testResult.success')
: t('mcp.testResult.failed')
}}</strong>
<span v-if="testResult.success">
{{ t('mcp.testResult.tools', { count: testResult.toolCount }) }} &middot;
{{ t('mcp.testResult.tools', { count: testResult.toolCount }) }} ·
{{ t('mcp.testResult.latency', { ms: testResult.latencyMs }) }}
</span>
<span v-else>{{ testResult.message }}</span>
</div>
</transition>
<!-- Create / Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal modal-wide">
<div class="modal-header">
<h2>{{ editing ? t('mcp.modal.editTitle') : t('mcp.modal.newTitle') }}</h2>
<button class="modal-close" @click="closeModal">&times;</button>
</div>
<div class="modal-body">
<div class="form-grid">
<div class="form-group">
<label class="form-label">{{ t('mcp.fields.name') }} *</label>
<input v-model="form.name" class="form-input" :placeholder="t('mcp.placeholders.name')" />
</div>
<div class="form-group">
<label class="form-label">{{ t('mcp.fields.transport') }} *</label>
<select v-model="form.transport" class="form-input">
<option value="stdio">Stdio</option>
<option value="sse">SSE</option>
<option value="streamable_http">Streamable HTTP</option>
</select>
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('mcp.fields.description') }}</label>
<input v-model="form.description" class="form-input" :placeholder="t('mcp.placeholders.description')" />
</div>
<template v-if="form.transport === 'stdio'">
<div class="form-group">
<label class="form-label">{{ t('mcp.fields.command') }} *</label>
<input v-model="form.command" class="form-input" :placeholder="t('mcp.placeholders.command')" />
</div>
<div class="form-group">
<label class="form-label">{{ t('mcp.fields.cwd') }}</label>
<input v-model="form.cwd" class="form-input" :placeholder="t('mcp.placeholders.cwd')" />
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('mcp.fields.args') }}</label>
<input v-model="form.argsJson" class="form-input mono" placeholder='["-y", "@modelcontextprotocol/server-xxx"]' />
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('mcp.fields.env') }}</label>
<textarea v-model="form.envJson" class="form-input form-textarea mono" placeholder='{"API_KEY": "xxx"}' rows="2"></textarea>
</div>
</template>
<template v-else>
<div class="form-group full-width">
<label class="form-label">{{ t('mcp.fields.url') }} *</label>
<input v-model="form.url" class="form-input mono" :placeholder="t('mcp.placeholders.url')" />
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('mcp.fields.headers') }}</label>
<textarea v-model="form.headersJson" class="form-input form-textarea mono" placeholder='{"Authorization": "Bearer xxx"}' rows="2"></textarea>
</div>
</template>
<div class="form-group">
<label class="form-label">{{ t('mcp.fields.connectTimeout') }}</label>
<input v-model.number="form.connectTimeoutSeconds" type="number" class="form-input" min="5" max="300" />
</div>
<div class="form-group">
<label class="form-label">{{ t('mcp.fields.readTimeout') }}</label>
<input v-model.number="form.readTimeoutSeconds" type="number" class="form-input" min="5" max="300" />
</div>
<div class="form-group full-width">
<label class="toggle-inline">
<input type="checkbox" v-model="form.enabled" />
{{ t('mcp.fields.enabled') }}
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="closeModal">{{ t('common.cancel') }}</button>
<button class="btn-primary" @click="saveServer" :disabled="!canSave">{{ t('common.save') }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import McPagination from '@/components/common/McPagination.vue'
import { mcConfirm } from '@/components/common/useConfirm'
import { mcpApi } from '@/api/index'
import { useMcpServers } from '@/composables/useMcpServers'
import McpCard from './mcp/McpCard.vue'
import McpFormModal from './mcp/McpFormModal.vue'
import McpEmptyState from './mcp/McpEmptyState.vue'
import type { McpServer, McpServerForm } from './mcp/types'
import type { McpCatalogEntry } from './mcp/catalog'
const { t } = useI18n()
interface McpServer {
id: number
name: string
description: string
transport: string
url: string
headersJson: string
command: string
argsJson: string
envJson: string
cwd: string
enabled: boolean
connectTimeoutSeconds: number
readTimeoutSeconds: number
lastStatus: string
lastError: string
lastConnectedTime: string
toolCount: number
builtin: boolean
}
const {
isLoading,
isRefreshing,
testingId,
testResult,
search,
pageSize,
installedPage,
catalogPage,
filteredInstalled,
filteredCatalog,
pagedInstalled,
pagedCatalog,
reload,
refreshAll,
saveServer,
removeServer,
testServer,
toggleServer,
} = useMcpServers()
interface TestResultData {
success: boolean
message: string
toolCount: number
latencyMs: number
discoveredTools: string[]
}
const modalVisible = ref(false)
const editingServer = ref<McpServer | null>(null)
const catalogPrefill = ref<McpCatalogEntry | null>(null)
const servers = ref<McpServer[]>([])
const showModal = ref(false)
const editing = ref<McpServer | null>(null)
const detailServer = ref<McpServer | null>(null)
const refreshing = ref(false)
const testingId = ref<number | null>(null)
const testResult = ref<TestResultData | null>(null)
const defaultForm = () => ({
name: '',
description: '',
transport: 'stdio',
url: '',
headersJson: '',
command: '',
argsJson: '',
envJson: '',
cwd: '',
connectTimeoutSeconds: 30,
readTimeoutSeconds: 30,
enabled: true,
})
const form = ref<any>(defaultForm())
const canSave = computed(() => {
if (!form.value.name) return false
if (form.value.transport === 'stdio' && !form.value.command) return false
if (form.value.transport !== 'stdio' && !form.value.url) return false
return true
})
onMounted(loadServers)
let loadGeneration = 0
async function loadServers() {
const gen = ++loadGeneration
try {
const res: any = await mcpApi.list()
if (gen === loadGeneration) {
servers.value = res.data || []
}
} catch (e: any) {
if (gen === loadGeneration) {
ElMessage.error(t('mcp.messages.loadFailed'))
}
}
}
const installedTotal = computed(() => filteredInstalled.value.length)
const catalogTotal = computed(() => filteredCatalog.value.length)
function openCreateModal() {
editing.value = null
form.value = defaultForm()
showModal.value = true
editingServer.value = null
catalogPrefill.value = null
modalVisible.value = true
}
function openEditModal(server: McpServer) {
editing.value = server
form.value = {
name: server.name,
description: server.description || '',
transport: server.transport,
url: server.url || '',
headersJson: server.headersJson || '',
command: server.command || '',
argsJson: server.argsJson || '',
envJson: server.envJson || '',
cwd: server.cwd || '',
connectTimeoutSeconds: server.connectTimeoutSeconds || 30,
readTimeoutSeconds: server.readTimeoutSeconds || 30,
enabled: server.enabled,
}
showModal.value = true
editingServer.value = server
catalogPrefill.value = null
modalVisible.value = true
}
function openFromCatalog(entry: McpCatalogEntry) {
editingServer.value = null
catalogPrefill.value = entry
modalVisible.value = true
}
function openDocs(url: string) {
window.open(url, '_blank', 'noopener,noreferrer')
}
function closeModal() {
showModal.value = false
editing.value = null
async function onSave(form: McpServerForm, editing: McpServer | null) {
const ok = await saveServer(form, editing)
if (ok) modalVisible.value = false
}
function openDetailModal(server: McpServer) {
detailServer.value = server
}
function closeDetailModal() {
detailServer.value = null
}
async function saveServer() {
try {
if (editing.value) {
await mcpApi.update(editing.value.id, form.value)
ElMessage.success(t('mcp.messages.updateSuccess'))
} else {
await mcpApi.create(form.value)
ElMessage.success(t('mcp.messages.createSuccess'))
}
closeModal()
await loadServers()
} catch (e: any) {
ElMessage.error(e?.message || t('mcp.messages.saveFailed'))
}
}
async function deleteServer(server: McpServer) {
const ok = await mcConfirm({
async function onDelete(server: McpServer) {
const confirmed = await mcConfirm({
title: t('common.delete'),
message: t('mcp.messages.deleteConfirm', { name: server.name }),
tone: 'danger',
})
if (!ok) return
try {
await mcpApi.delete(server.id)
ElMessage.success(t('mcp.messages.deleteSuccess'))
await loadServers()
} catch (e: any) {
ElMessage.error(e?.message || t('mcp.messages.saveFailed'))
}
if (!confirmed) return
const ok = await removeServer(server)
if (ok) modalVisible.value = false
}
async function toggleServer(server: McpServer) {
try {
await mcpApi.toggle(server.id, !server.enabled)
ElMessage.success(t('mcp.messages.toggleSuccess'))
await loadServers()
} catch (e: any) {
ElMessage.error(e?.message || t('mcp.messages.saveFailed'))
}
}
async function testConnection(server: McpServer) {
testingId.value = server.id
testResult.value = null
try {
const res: any = await mcpApi.test(server.id)
testResult.value = res.data
setTimeout(() => { testResult.value = null }, 4000)
} catch (e: any) {
testResult.value = { success: false, message: e?.message || 'Unknown error', toolCount: 0, latencyMs: 0, discoveredTools: [] }
setTimeout(() => { testResult.value = null }, 4000)
} finally {
testingId.value = null
}
}
async function refreshAll() {
refreshing.value = true
try {
await mcpApi.refresh()
ElMessage.success(t('mcp.messages.refreshSuccess'))
await loadServers()
} catch (e: any) {
ElMessage.error(e?.message || t('mcp.messages.saveFailed'))
} finally {
refreshing.value = false
}
}
function truncate(str: string, len: number) {
return str && str.length > len ? str.substring(0, len) + '...' : str
}
function formatRelativeTime(dateStr: string): string {
if (!dateStr) return ''
const now = Date.now()
const time = new Date(dateStr).getTime()
const diff = now - time
if (diff < 0) return ''
const sec = Math.floor(diff / 1000)
if (sec < 60) return t('chat.timeJustNow', 'Just now')
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m ago`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr}h ago`
const d = new Date(dateStr)
const p = (n: number) => String(n).padStart(2, '0')
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
onMounted(reload)
</script>
<style scoped>
/* ===== Shell ===== */
.page-container { height: 100%; overflow-y: auto; }
.page-shell { padding: 24px; }
/* ===== Header ===== */
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.page-lead { display: flex; flex-direction: column; gap: 6px; }
.page-kicker {
display: inline-flex; width: fit-content;
padding: 4px 10px; border-radius: 999px;
font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--mc-primary); background: var(--mc-primary-bg);
/* Mirror the Settings sub-page convention used by Settings/Models/index.vue
etc. Settings/Layout.vue already wraps every sub-route in the global
.mc-page-* frame, so this page renders directly inside the existing
.settings-content__inner without adding its own shell. */
.settings-section { width: 100%; }
.section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 20px;
}
.page-title { font-size: clamp(24px, 3.5vw, 36px); font-weight: 800; color: var(--mc-text-primary); margin: 0; }
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; }
.header-actions { display: flex; gap: 8px; }
.section-title { margin: 0 0 6px; font-size: 22px; font-weight: 700; color: var(--mc-text-primary); }
.section-desc { margin: 0; font-size: 14px; color: var(--mc-text-secondary); }
.section-header__actions { display: flex; gap: 8px; flex-shrink: 0; }
/* ===== Buttons ===== */
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 9px 16px; background: var(--mc-primary); color: #fff; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; white-space: nowrap; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { opacity: .4; cursor: not-allowed; }
.btn-secondary { display: flex; align-items: center; gap: 6px; padding: 9px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 14px; cursor: pointer; white-space: nowrap; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.btn-secondary:disabled { opacity: .4; cursor: not-allowed; }
/* ===== Table — one surface, no nesting ===== */
.table-wrap {
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 16px;
overflow: hidden;
}
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
padding: 10px 16px; text-align: left;
font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase;
color: var(--mc-text-tertiary);
background: var(--mc-bg-sunken);
border-bottom: 1px solid var(--mc-border);
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 14px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
white-space: nowrap;
}
.th-center { text-align: center; }
.data-row { border-bottom: 1px solid var(--mc-border-light); transition: background .12s; }
.data-row:last-child { border-bottom: none; }
.data-row:hover { background: var(--mc-bg-sunken); }
.data-table td { padding: 12px 16px; font-size: 14px; color: var(--mc-text-primary); vertical-align: middle; }
.td-center { text-align: center; }
/* ===== Server name cell ===== */
.server-info { display: flex; align-items: center; gap: 10px; }
.server-icon {
width: 32px; height: 32px; border-radius: 8px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
background: var(--mc-bg-sunken); color: var(--mc-text-tertiary);
.btn-primary {
background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover));
color: #fff;
border: none;
font-weight: 600;
box-shadow: var(--mc-shadow-soft);
}
.icon-connected { color: var(--mc-primary); background: var(--mc-primary-bg); }
.icon-error { color: var(--mc-danger, #ef4444); background: color-mix(in srgb, var(--mc-danger, #ef4444) 10%, transparent); }
.server-name { font-weight: 600; color: var(--mc-text-primary); }
.server-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.transport-tag {
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em;
padding: 1px 6px; border-radius: 4px;
color: var(--mc-text-tertiary); background: var(--mc-bg-sunken);
.btn-primary:hover { filter: brightness(1.04); }
.btn-secondary {
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
border: 1px solid var(--mc-border);
}
.server-desc { font-size: 12px; color: var(--mc-text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
/* ===== Status cell ===== */
.status-row { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.dot-connected { background: #34d399; box-shadow: 0 0 4px rgba(52,211,153,.45); }
.dot-disconnected { background: var(--mc-text-tertiary); opacity: .4; }
.dot-error { background: var(--mc-danger, #ef4444); box-shadow: 0 0 4px rgba(239,68,68,.4); }
.status-label { font-size: 13px; font-weight: 500; color: var(--mc-text-secondary); }
.status-time { font-size: 12px; color: var(--mc-text-tertiary); }
.status-time::before { content: '\00b7'; margin: 0 3px; }
.status-error { font-size: 11px; color: var(--mc-danger, #ef4444); margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 240px; }
.mcp-filter-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
margin-bottom: 22px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border-light);
border-radius: 12px;
}
.mcp-filter-bar > svg { color: var(--mc-text-tertiary); flex-shrink: 0; }
.mcp-search-input {
flex: 1;
padding: 4px 0;
border: none;
background: transparent;
color: var(--mc-text-primary);
font-size: 14px;
outline: none;
}
.mcp-search-input::placeholder { color: var(--mc-text-tertiary); }
/* ===== Count pill ===== */
.count-pill {
display: inline-block; min-width: 24px; padding: 1px 8px;
border-radius: 8px; font-size: 13px; font-weight: 600; text-align: center;
color: var(--mc-text-secondary); background: var(--mc-bg-sunken);
.mcp-block { margin-bottom: 28px; }
.mcp-block:last-child { margin-bottom: 0; }
.mcp-block-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.mcp-block-title {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--mc-text-tertiary);
}
.mcp-block-count {
font-size: 12px;
color: var(--mc-text-tertiary);
}
/* ===== Toggle ===== */
.toggle { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-track { position: absolute; inset: 0; background: var(--mc-border); border-radius: 20px; transition: .2s; }
.toggle-track::before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; top: 3px; background: var(--mc-bg-elevated); border-radius: 50%; transition: .2s; }
.toggle input:checked + .toggle-track { background: var(--mc-primary); }
.toggle input:checked + .toggle-track::before { transform: translateX(16px); }
/* ===== Row actions ===== */
.row-actions { display: flex; gap: 5px; }
.row-btn {
width: 30px; height: 30px; border: 1px solid var(--mc-border); border-radius: 8px;
background: transparent; cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: var(--mc-text-tertiary); transition: all .12s;
/* Auto-fill responsive grid cards reflow to fit available width.
`minmax(min(320px, 100%), 1fr)` forces a single column on viewports
narrower than the min track size, and the `minmax(0, ...)` semantics
keep `text-overflow: ellipsis` working on long card descriptions. */
.mcp-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
gap: 12px;
}
.row-btn:hover { background: var(--mc-bg-sunken); color: var(--mc-text-primary); }
.row-btn:disabled { opacity: .3; cursor: not-allowed; }
.row-btn.danger:hover { background: color-mix(in srgb, var(--mc-danger, #ef4444) 10%, transparent); border-color: var(--mc-danger); color: var(--mc-danger); }
/* ===== Empty state ===== */
.empty-row { padding: 48px 16px !important; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 6px; color: var(--mc-text-tertiary); }
.empty-state p { font-size: 14px; margin: 0; }
.empty-sub { font-size: 12px; }
.mcp-pager-row {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
/* ===== Toast ===== */
.test-toast { position: fixed; bottom: 24px; right: 24px; padding: 12px 18px; border-radius: 10px; z-index: 2000; box-shadow: 0 4px 16px rgba(0,0,0,.15); display: flex; align-items: center; gap: 8px; font-size: 13px; }
.test-toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 12px 18px;
border-radius: 10px;
z-index: 2000;
box-shadow: var(--mc-shadow-medium);
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.toast-ok { background: var(--mc-primary); color: #fff; }
.toast-fail { background: var(--mc-danger, #ef4444); color: #fff; }
.toast-enter-active, .toast-leave-active { transition: all .25s ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(16px); }
.toast-enter-active,
.toast-leave-active { transition: all 0.25s ease; }
.toast-enter-from,
.toast-leave-to { opacity: 0; transform: translateY(16px); }
/* ===== Modal ===== */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
.modal { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 14px; width: 100%; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 16px 48px rgba(0,0,0,.18); }
.modal-wide { max-width: 580px; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--mc-border-light); }
.modal-header h2 { font-size: 17px; font-weight: 600; color: var(--mc-text-primary); margin: 0; }
.modal-close { width: 28px; height: 28px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); font-size: 20px; display: flex; align-items: center; justify-content: center; border-radius: 6px; }
.modal-close:hover { background: var(--mc-bg-sunken); }
.modal-body { flex: 1; overflow-y: auto; padding: 18px 22px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 22px; border-top: 1px solid var(--mc-border-light); }
/* Detail grid */
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.detail-item { display: flex; flex-direction: column; gap: 4px; }
.detail-full { grid-column: 1 / -1; }
.detail-label { font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--mc-text-tertiary); }
.detail-value { font-size: 14px; color: var(--mc-text-primary); word-break: break-word; }
.detail-sub { font-size: 12px; color: var(--mc-text-tertiary); }
.detail-code { padding: 8px 12px; border-radius: 8px; background: var(--mc-bg-sunken); font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; white-space: pre-wrap; }
/* Form */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.form-group { display: flex; flex-direction: column; gap: 4px; }
.form-group.full-width { grid-column: 1 / -1; }
.form-label { font-size: 13px; font-weight: 500; color: var(--mc-text-secondary); }
.form-input { padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; color: var(--mc-text-primary); outline: none; background: var(--mc-bg-sunken); width: 100%; }
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,.12); }
.form-textarea { resize: vertical; min-height: 40px; font-family: 'SF Mono', 'Fira Code', monospace; }
.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; }
.toggle-inline { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--mc-text-primary); cursor: pointer; }
.toggle-inline input { width: 16px; height: 16px; accent-color: var(--mc-primary); }
/* Anim */
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin 1s linear infinite; }
/* Responsive */
@media (max-width: 900px) {
.page-header { flex-direction: column; }
.header-actions { width: 100%; }
.btn-primary, .btn-secondary { flex: 1; justify-content: center; }
.detail-grid { grid-template-columns: 1fr; }
@media (max-width: 720px) {
.section-header { flex-direction: column; }
.section-header__actions { width: 100%; }
.btn-primary,
.btn-secondary { flex: 1; justify-content: center; }
}
</style>

View File

@ -0,0 +1,397 @@
<template>
<div
class="mcp-card"
:class="{ 'mcp-card--catalog': isCatalog }"
role="button"
tabindex="0"
@click="onPrimaryAction"
@keydown.enter.prevent="onPrimaryAction"
@keydown.space.prevent="onPrimaryAction"
>
<McpServerIcon :name="displayName" :icon-key="iconKey" :variant="iconVariant" />
<div class="mcp-card-body">
<div class="mcp-card-name-row">
<span v-if="!isCatalog" class="mcp-status-dot" :class="statusClass" />
<span class="mcp-card-name">{{ displayName }}</span>
<span class="mcp-transport-pill">
<svg v-if="isHttpTransport" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
<svg v-else width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
</svg>
{{ transportLabel }}
</span>
</div>
<div class="mcp-card-desc" :class="{ 'mcp-card-desc--error': hasError }">
{{ descriptionText }}
</div>
<div v-if="!isCatalog" class="mcp-card-meta">
{{ t('mcp.card.toolCount', { n: server!.toolCount || 0 }) }}
<template v-if="lastSeen"> · {{ lastSeen }}</template>
</div>
</div>
<!-- Always-visible enable toggle (installed only). Sits outside the
hover-reveal actions strip so the user can flip it without
hovering / opening the form, and the disabled state stays
legible while the card is idle. -->
<label
v-if="!isCatalog"
class="mcp-card-toggle"
:class="{ 'mcp-card-toggle--on': server!.enabled }"
:title="server!.enabled ? t('mcp.toggle.disable') : t('mcp.toggle.enable')"
@click.stop
>
<input
type="checkbox"
:checked="server!.enabled"
@change="emit('toggle', server!)"
/>
<span class="mcp-card-toggle-track" />
</label>
<div class="mcp-card-actions" @click.stop>
<template v-if="isCatalog">
<button
v-if="catalogEntry!.docsUrl"
class="mcp-card-action"
:title="t('mcp.card.docs')"
:aria-label="t('mcp.card.docs')"
@click="emit('docs', catalogEntry!.docsUrl)"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</button>
<button
class="mcp-card-action"
:title="t('mcp.card.add')"
:aria-label="t('mcp.card.add')"
@click="emit('add', catalogEntry!)"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</template>
<template v-else>
<button
class="mcp-card-action"
:title="t('mcp.actions.test')"
:aria-label="t('mcp.actions.test')"
:disabled="testing"
@click="emit('test', server!)"
>
<svg v-if="!testing" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<svg v-else class="spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/>
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/>
<line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/>
</svg>
</button>
<button
class="mcp-card-action"
:title="t('common.edit')"
:aria-label="t('common.edit')"
@click="emit('edit', server!)"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { McpServer } from './types'
import type { McpCatalogEntry } from './catalog'
import McpServerIcon from './McpServerIcon.vue'
const props = defineProps<{
server?: McpServer
catalogEntry?: McpCatalogEntry
testing?: boolean
}>()
const emit = defineEmits<{
(e: 'edit', server: McpServer): void
(e: 'test', server: McpServer): void
(e: 'toggle', server: McpServer): void
(e: 'add', entry: McpCatalogEntry): void
(e: 'docs', url: string): void
}>()
const { t } = useI18n()
const isCatalog = computed(() => !!props.catalogEntry)
const displayName = computed(() =>
isCatalog.value ? props.catalogEntry!.name : props.server!.name,
)
// Catalog entries carry their iconKey explicitly. Installed entries reuse
// their `name` as the key so a server named `figma` or `github` picks up
// the same brand glyph as the catalog card it shipped from.
const iconKey = computed(() =>
isCatalog.value ? props.catalogEntry!.iconKey : props.server!.name?.toLowerCase(),
)
const iconVariant = computed<'installed-ok' | 'installed-err' | 'installed' | 'brand'>(() => {
if (isCatalog.value) return 'brand'
const s = props.server!.lastStatus
if (s === 'connected') return 'installed-ok'
if (s === 'error') return 'installed-err'
return 'installed'
})
const transportLabel = computed(() => {
const tp = isCatalog.value ? props.catalogEntry!.config.transport : props.server!.transport
if (tp === 'stdio') return 'STDIO'
if (tp === 'sse') return 'SSE'
return 'HTTP'
})
const isHttpTransport = computed(() => {
const tp = isCatalog.value ? props.catalogEntry!.config.transport : props.server!.transport
return tp === 'streamable_http' || tp === 'sse'
})
const statusClass = computed(() => {
const s = props.server?.lastStatus
if (s === 'connected') return 'mcp-status-dot--ok'
if (s === 'error') return 'mcp-status-dot--err'
return 'mcp-status-dot--off'
})
const hasError = computed(
() => !isCatalog.value && props.server!.lastStatus === 'error' && !!props.server!.lastError,
)
const descriptionText = computed(() => {
if (isCatalog.value) return props.catalogEntry!.description
if (hasError.value) {
const err = props.server!.lastError
return err.length > 60 ? err.slice(0, 60) + '…' : err
}
return props.server!.description || ''
})
const lastSeen = computed(() => {
if (isCatalog.value || !props.server!.lastConnectedTime) return ''
return formatRelative(props.server!.lastConnectedTime)
})
function formatRelative(iso: string): string {
const time = new Date(iso).getTime()
if (!Number.isFinite(time)) return ''
const diff = Date.now() - time
if (diff < 0) return ''
const min = Math.floor(diff / 60000)
if (min < 1) return t('mcp.time.justNow')
if (min < 60) return t('mcp.time.minutesAgo', { n: min })
const hr = Math.floor(min / 60)
if (hr < 24) return t('mcp.time.hoursAgo', { n: hr })
const d = new Date(iso)
const p = (n: number) => String(n).padStart(2, '0')
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
function onPrimaryAction() {
if (isCatalog.value) emit('add', props.catalogEntry!)
else emit('edit', props.server!)
}
</script>
<style scoped>
.mcp-card {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border-light);
border-radius: 12px;
cursor: pointer;
transition: all 0.15s ease;
outline: none;
}
.mcp-card:hover {
border-color: var(--mc-border-strong);
box-shadow: var(--mc-shadow-soft);
transform: translateY(-1px);
}
.mcp-card:focus-visible {
border-color: var(--mc-primary);
box-shadow: 0 0 0 3px var(--mc-primary-bg);
}
/* Icon container is owned by McpServerIcon.vue. */
.mcp-card-body {
min-width: 0;
flex: 1;
/* When hover-reveal actions slide in (absolute, right of body), they
otherwise float on top of the description's tail letters. Reserving
right-padding on hover lets `text-overflow: ellipsis` trim the text
before it reaches the action strip. */
transition: padding-right 0.15s ease;
}
.mcp-card:hover .mcp-card-body,
.mcp-card:focus-within .mcp-card-body {
padding-right: 64px;
}
/* Catalog cards have no toggle — actions sit flush right, so reserve less. */
.mcp-card--catalog:hover .mcp-card-body,
.mcp-card--catalog:focus-within .mcp-card-body {
padding-right: 52px;
}
.mcp-card-name-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.mcp-card-name {
font-size: 13.5px;
font-weight: 600;
color: var(--mc-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mcp-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.mcp-status-dot--ok {
background: #34d399;
box-shadow: 0 0 4px rgba(52, 211, 153, 0.45);
}
.mcp-status-dot--err {
background: var(--mc-danger, #ef4444);
box-shadow: 0 0 4px rgba(239, 68, 68, 0.4);
}
.mcp-status-dot--off { background: var(--mc-text-tertiary); opacity: 0.4; }
.mcp-transport-pill {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
background: var(--mc-bg-sunken);
color: var(--mc-text-tertiary);
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
flex-shrink: 0;
}
.mcp-card-desc {
margin-top: 2px;
font-size: 12px;
color: var(--mc-text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mcp-card-desc--error { color: var(--mc-danger, #ef4444); }
.mcp-card-meta {
margin-top: 4px;
font-size: 11px;
color: var(--mc-text-tertiary);
}
/* Always-visible enable toggle on installed cards. Sits to the right
of the card body; hover-reveal actions slide in to its left. */
.mcp-card-toggle {
position: relative;
display: inline-block;
width: 32px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
}
.mcp-card-toggle input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
margin: 0;
cursor: pointer;
}
.mcp-card-toggle-track {
position: absolute;
inset: 0;
background: var(--mc-border);
border-radius: 999px;
transition: background 0.18s;
}
.mcp-card-toggle-track::before {
content: '';
position: absolute;
width: 12px;
height: 12px;
left: 3px;
top: 3px;
background: var(--mc-bg-elevated);
border-radius: 50%;
transition: transform 0.18s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
}
.mcp-card-toggle--on .mcp-card-toggle-track { background: var(--mc-primary); }
.mcp-card-toggle--on .mcp-card-toggle-track::before { transform: translateX(14px); }
.mcp-card-actions {
display: flex;
gap: 2px;
/* Sit to the left of the always-visible toggle (toggle 32px + gap 12px). */
position: absolute;
right: 56px;
top: 50%;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.15s;
}
/* Catalog cards have no toggle — actions go all the way right. */
.mcp-card--catalog .mcp-card-actions { right: 10px; }
.mcp-card:hover .mcp-card-actions,
.mcp-card:focus-within .mcp-card-actions { opacity: 1; }
.mcp-card-action {
width: 28px;
height: 28px;
border: none;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--mc-text-tertiary);
border-radius: 7px;
cursor: pointer;
}
.mcp-card-action:hover {
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
}
.mcp-card-action:disabled { cursor: not-allowed; opacity: 0.4; }
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin 1s linear infinite; }
</style>

View File

@ -0,0 +1,77 @@
<template>
<div class="mcp-empty">
<svg
class="mcp-empty-icon"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.4"
>
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
<line x1="6" y1="6" x2="6.01" y2="6" />
<line x1="6" y1="18" x2="6.01" y2="18" />
</svg>
<p class="mcp-empty-title">{{ title }}</p>
<p v-if="hint" class="mcp-empty-hint">{{ hint }}</p>
<button v-if="kind === 'initial'" class="mcp-empty-cta" @click="$emit('add')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{{ t('mcp.addCustom') }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
kind: 'initial' | 'search'
}>()
defineEmits<{ (e: 'add'): void }>()
const { t } = useI18n()
const title = computed(() =>
props.kind === 'search' ? t('mcp.emptyMatch') : t('mcp.messages.empty'),
)
const hint = computed(() =>
props.kind === 'search' ? '' : t('mcp.messages.emptyDesc'),
)
</script>
<style scoped>
.mcp-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 56px 24px;
color: var(--mc-text-tertiary);
text-align: center;
}
.mcp-empty-icon { opacity: 0.35; }
.mcp-empty-title { font-size: 14px; margin: 4px 0 0; }
.mcp-empty-hint { font-size: 12px; margin: 0; }
.mcp-empty-cta {
margin-top: 10px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--mc-primary);
color: #fff;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.mcp-empty-cta:hover { background: var(--mc-primary-hover); }
</style>

View File

@ -0,0 +1,450 @@
<template>
<div v-if="modelValue" class="modal-overlay" @click.self="close">
<div class="modal modal-wide">
<div class="modal-header">
<h2>
{{ headerTitle }}
<span v-if="catalogBadge" class="catalog-badge">{{ catalogBadge }}</span>
</h2>
<button class="modal-close" @click="close" :aria-label="t('common.cancel')">&times;</button>
</div>
<div class="modal-body">
<div v-if="credentialHint" class="credential-hint">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 9v4M12 17h.01"/>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
</svg>
<span>{{ credentialHint }}</span>
</div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">{{ t('mcp.fields.name') }} *</label>
<input v-model="form.name" class="form-input" :placeholder="t('mcp.placeholders.name')" />
</div>
<div class="form-group">
<label class="form-label">{{ t('mcp.fields.transport') }} *</label>
<select v-model="form.transport" class="form-input">
<option value="stdio">Stdio</option>
<option value="sse">SSE</option>
<option value="streamable_http">Streamable HTTP</option>
</select>
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('mcp.fields.description') }}</label>
<input v-model="form.description" class="form-input" :placeholder="t('mcp.placeholders.description')" />
</div>
<template v-if="form.transport === 'stdio'">
<div class="form-group">
<label class="form-label">{{ t('mcp.fields.command') }} *</label>
<input v-model="form.command" class="form-input" :placeholder="t('mcp.placeholders.command')" />
</div>
<div class="form-group">
<label class="form-label">{{ t('mcp.fields.cwd') }}</label>
<input v-model="form.cwd" class="form-input" :placeholder="t('mcp.placeholders.cwd')" />
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('mcp.fields.args') }}</label>
<input
v-model="form.argsJson"
class="form-input mono"
placeholder='["-y", "@modelcontextprotocol/server-xxx"]'
/>
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('mcp.fields.env') }}</label>
<McpKvEditor
v-model="form.envJson"
:key-placeholder="t('mcp.kv.envKey')"
:value-placeholder="t('mcp.kv.envValue')"
:add-label="t('mcp.kv.addEnv')"
:credential-keys="catalogSource?.credentialKeys"
/>
</div>
</template>
<template v-else>
<div class="form-group full-width">
<label class="form-label">{{ t('mcp.fields.url') }} *</label>
<input
v-model="form.url"
class="form-input mono"
:placeholder="t('mcp.placeholders.url')"
/>
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('mcp.fields.headers') }}</label>
<McpKvEditor
v-model="form.headersJson"
:key-placeholder="t('mcp.kv.headerKey')"
:value-placeholder="t('mcp.kv.headerValue')"
:add-label="t('mcp.kv.addHeader')"
:credential-keys="catalogSource?.credentialKeys"
/>
</div>
</template>
<div class="form-group">
<label class="form-label">{{ t('mcp.fields.connectTimeout') }}</label>
<input
v-model.number="form.connectTimeoutSeconds"
type="number"
class="form-input"
min="5"
max="300"
/>
</div>
<div class="form-group">
<label class="form-label">{{ t('mcp.fields.readTimeout') }}</label>
<input
v-model.number="form.readTimeoutSeconds"
type="number"
class="form-input"
min="5"
max="300"
/>
</div>
<div class="form-group full-width">
<label class="toggle-inline">
<input type="checkbox" v-model="form.enabled" />
{{ t('mcp.fields.enabled') }}
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button
v-if="editing"
class="btn-danger"
@click="onDelete"
>
{{ t('common.delete') }}
</button>
<div class="footer-spacer" />
<button class="btn-secondary" @click="close">{{ t('common.cancel') }}</button>
<button class="btn-primary" :disabled="!canSave" @click="onSave">
{{ t('common.save') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { emptyMcpForm, type McpServer, type McpServerForm } from './types'
import type { McpCatalogEntry } from './catalog'
import McpKvEditor from './McpKvEditor.vue'
const props = defineProps<{
modelValue: boolean
editing?: McpServer | null
prefill?: McpCatalogEntry | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'save', form: McpServerForm, editing: McpServer | null): void
(e: 'delete', server: McpServer): void
}>()
const { t } = useI18n()
const form = reactive<McpServerForm>(emptyMcpForm())
// Capture the catalog source for the header badge even if the user
// changes transport / fields, the badge keeps showing where it came from
// until the modal closes.
const catalogSource = ref<McpCatalogEntry | null>(null)
watch(
() => props.modelValue,
(visible) => {
if (!visible) return
if (props.editing) {
hydrateFromServer(props.editing)
catalogSource.value = null
} else if (props.prefill) {
hydrateFromCatalog(props.prefill)
catalogSource.value = props.prefill
} else {
Object.assign(form, emptyMcpForm())
catalogSource.value = null
}
},
{ immediate: true },
)
function hydrateFromServer(s: McpServer) {
Object.assign(form, {
name: s.name,
description: s.description || '',
transport: s.transport,
url: s.url || '',
headersJson: s.headersJson || '',
command: s.command || '',
argsJson: s.argsJson || '',
envJson: s.envJson || '',
cwd: s.cwd || '',
connectTimeoutSeconds: s.connectTimeoutSeconds || 30,
readTimeoutSeconds: s.readTimeoutSeconds || 30,
enabled: s.enabled,
})
}
function hydrateFromCatalog(entry: McpCatalogEntry) {
const fresh = emptyMcpForm()
fresh.name = entry.key
fresh.description = entry.description
fresh.transport = entry.config.transport
if (entry.config.transport === 'stdio') {
fresh.command = entry.config.command
fresh.argsJson = entry.config.argsJson || ''
fresh.envJson = entry.config.envJson || ''
} else {
fresh.url = entry.config.url
fresh.headersJson = entry.config.headersJson || ''
}
Object.assign(form, fresh)
}
const headerTitle = computed(() =>
props.editing ? t('mcp.modal.editTitle') : t('mcp.modal.newTitle'),
)
const catalogBadge = computed(() => {
const src = catalogSource.value
return src ? t('mcp.catalogBadge', { name: src.name }) : ''
})
/**
* Surface catalog credential expectations in two buckets so the user can
* tell at a glance which keys MUST be filled (server won't auth otherwise)
* vs which keys are optional capability unlocks. Single-bucket catalogs
* fall back to the simpler "required only" / "optional only" phrasings.
*/
const credentialHint = computed(() => {
const src = catalogSource.value
if (!src || !src.credentialKeys || src.credentialKeys.length === 0) return ''
const required = src.credentialKeys.filter(k => k.required).map(k => k.key)
const optional = src.credentialKeys.filter(k => !k.required).map(k => k.key)
if (required.length && optional.length) {
return t('mcp.credentialHintBoth', {
required: required.join(', '),
optional: optional.join(', '),
})
}
if (required.length) {
return t('mcp.credentialHintRequired', { keys: required.join(', ') })
}
return t('mcp.credentialHintOptional', { keys: optional.join(', ') })
})
const canSave = computed(() => {
if (!form.name) return false
if (form.transport === 'stdio' && !form.command) return false
if (form.transport !== 'stdio' && !form.url) return false
return true
})
function close() { emit('update:modelValue', false) }
function onSave() {
if (!canSave.value) return
emit('save', { ...form }, props.editing ?? null)
}
function onDelete() {
if (props.editing) emit('delete', props.editing)
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal {
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 14px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: var(--mc-shadow-medium);
}
.modal-wide { max-width: 580px; }
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
border-bottom: 1px solid var(--mc-border-light);
gap: 12px;
}
.modal-header h2 {
font-size: 17px;
font-weight: 600;
color: var(--mc-text-primary);
margin: 0;
display: inline-flex;
align-items: center;
gap: 10px;
}
.catalog-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
background: var(--mc-primary-bg);
color: var(--mc-primary);
border-radius: 999px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.modal-close {
width: 28px;
height: 28px;
border: none;
background: none;
cursor: pointer;
color: var(--mc-text-tertiary);
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
}
.modal-close:hover { background: var(--mc-bg-sunken); }
.modal-body { flex: 1; overflow-y: auto; padding: 18px 22px; }
.credential-hint {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0 0 14px;
padding: 9px 12px;
background: rgba(252, 211, 77, 0.14);
border: 1px solid rgba(252, 211, 77, 0.35);
color: var(--mc-text-primary);
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
}
.credential-hint svg { flex-shrink: 0; color: #d97706; margin-top: 1px; }
.modal-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 22px;
border-top: 1px solid var(--mc-border-light);
}
.footer-spacer { flex: 1; }
.btn-primary {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 16px;
background: var(--mc-primary);
color: #fff;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-secondary {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 16px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
border: 1px solid var(--mc-border);
border-radius: 10px;
font-size: 14px;
cursor: pointer;
}
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.btn-danger {
padding: 9px 14px;
background: transparent;
color: var(--mc-danger, #ef4444);
border: 1px solid var(--mc-danger, #ef4444);
border-radius: 10px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.btn-danger:hover { background: rgba(239, 68, 68, 0.08); }
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group.full-width { grid-column: 1 / -1; }
.form-label {
font-size: 13px;
font-weight: 500;
color: var(--mc-text-secondary);
}
.form-input {
padding: 8px 12px;
border: 1px solid var(--mc-border);
border-radius: 8px;
font-size: 14px;
color: var(--mc-text-primary);
outline: none;
background: var(--mc-bg-sunken);
width: 100%;
}
.form-input:focus {
border-color: var(--mc-primary);
box-shadow: 0 0 0 2px var(--mc-primary-bg);
}
.form-textarea {
resize: vertical;
min-height: 40px;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.mono {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 13px;
}
.toggle-inline {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--mc-text-primary);
cursor: pointer;
}
.toggle-inline input {
width: 16px;
height: 16px;
accent-color: var(--mc-primary);
}
@media (max-width: 640px) {
.form-grid { grid-template-columns: 1fr; }
}
</style>

View File

@ -0,0 +1,265 @@
<template>
<div class="kv-editor">
<div
v-for="(pair, idx) in pairs"
:key="idx"
class="kv-row"
:class="{ 'kv-row--credential': isCredentialKey(pair.key) }"
>
<div class="kv-key-wrap">
<input
v-model="pair.key"
class="kv-input kv-input--key"
:placeholder="keyPlaceholder || 'KEY'"
autocomplete="off"
spellcheck="false"
@input="emitChange"
/>
<span
v-if="isCredentialKey(pair.key)"
class="kv-credential-badge"
:class="isRequiredCredential(pair.key) ? 'kv-credential-badge--required' : 'kv-credential-badge--optional'"
>
{{ isRequiredCredential(pair.key) ? t('mcp.kv.required') : t('mcp.kv.optional') }}
</span>
</div>
<input
v-model="pair.value"
class="kv-input kv-input--value"
:class="{ 'kv-input--placeholder': isPlaceholderValue(pair.value) }"
:placeholder="valuePlaceholder || 'value'"
autocomplete="off"
spellcheck="false"
@input="emitChange"
/>
<button
type="button"
class="kv-remove"
:title="t('common.delete')"
:aria-label="t('common.delete')"
@click="removeRow(idx)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="6" y1="6" x2="18" y2="18" />
<line x1="6" y1="18" x2="18" y2="6" />
</svg>
</button>
</div>
<button type="button" class="kv-add" @click="addRow">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{{ addLabel || t('mcp.kv.addEnv') }}
</button>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
modelValue: string
keyPlaceholder?: string
valuePlaceholder?: string
addLabel?: string
/** Catalog-supplied credential keys when a row's key matches, render
a "required" / "optional" badge so the user knows which entries
need real secrets. */
credentialKeys?: ReadonlyArray<{ key: string; required: boolean }>
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const { t } = useI18n()
interface Pair {
key: string
value: string
}
const pairs = ref<Pair[]>([{ key: '', value: '' }])
function parse(json: string): Pair[] {
if (!json || !json.trim()) return [{ key: '', value: '' }]
try {
const obj = JSON.parse(json) as unknown
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
return [{ key: '', value: '' }]
}
const list = Object.entries(obj as Record<string, unknown>).map(([k, v]) => ({
key: k,
value: v == null ? '' : String(v),
}))
return list.length ? list : [{ key: '', value: '' }]
} catch {
return [{ key: '', value: '' }]
}
}
function serialize(list: Pair[]): string {
const obj: Record<string, string> = {}
for (const { key, value } of list) {
const k = key.trim()
if (!k) continue
obj[k] = value
}
return Object.keys(obj).length === 0 ? '' : JSON.stringify(obj, null, 2)
}
watch(
() => props.modelValue,
(val) => {
// Only re-parse from props if the serialized form actually differs
// prevents the watcher from clobbering rows the user is typing into
// (e.g. a half-typed key that hasn't been committed to JSON yet).
if (val !== serialize(pairs.value)) {
pairs.value = parse(val)
}
},
{ immediate: true },
)
function emitChange() {
emit('update:modelValue', serialize(pairs.value))
}
function addRow() {
pairs.value.push({ key: '', value: '' })
}
// Catalog placeholder values look like `YOUR_API_KEY`, `YOUR_TOKEN`, etc.
// Detecting them lets the input tint amber until the user replaces the
// stub with a real secret.
const PLACEHOLDER_PATTERN = /^YOUR[_-][A-Z0-9_]+$/i
function isCredentialKey(key: string): boolean {
const k = (key || '').trim()
if (!k || !props.credentialKeys) return false
return props.credentialKeys.some(c => c.key === k)
}
function isRequiredCredential(key: string): boolean {
const k = (key || '').trim()
if (!k || !props.credentialKeys) return false
return props.credentialKeys.some(c => c.key === k && c.required)
}
function isPlaceholderValue(value: string): boolean {
return PLACEHOLDER_PATTERN.test((value || '').trim())
}
function removeRow(idx: number) {
pairs.value.splice(idx, 1)
// Keep at least one empty row so the editor always has a typing target.
if (pairs.value.length === 0) pairs.value.push({ key: '', value: '' })
emitChange()
}
</script>
<style scoped>
.kv-editor { display: flex; flex-direction: column; gap: 6px; }
.kv-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto;
gap: 6px;
align-items: center;
}
.kv-key-wrap {
position: relative;
display: flex;
align-items: center;
min-width: 0;
}
.kv-key-wrap .kv-input--key {
/* Reserve room on the right for the credential badge. */
padding-right: 60px;
}
.kv-credential-badge {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
padding: 1px 6px;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
border-radius: 4px;
pointer-events: none;
white-space: nowrap;
}
.kv-credential-badge--required {
background: rgba(217, 119, 87, 0.12);
color: var(--mc-primary);
}
.kv-credential-badge--optional {
background: var(--mc-bg-sunken);
color: var(--mc-text-tertiary);
}
.kv-input--placeholder {
background: rgba(252, 211, 77, 0.08);
border-color: rgba(252, 211, 77, 0.35);
color: var(--mc-text-secondary);
}
.kv-input {
padding: 7px 10px;
border: 1px solid var(--mc-border);
border-radius: 8px;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12.5px;
color: var(--mc-text-primary);
background: var(--mc-bg-sunken);
outline: none;
min-width: 0;
width: 100%;
}
.kv-input:focus {
border-color: var(--mc-primary);
box-shadow: 0 0 0 2px var(--mc-primary-bg);
}
.kv-input::placeholder {
color: var(--mc-text-tertiary);
letter-spacing: 0.02em;
}
.kv-input--key::placeholder { text-transform: uppercase; }
.kv-remove {
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--mc-text-tertiary);
cursor: pointer;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.kv-remove:hover { background: var(--mc-bg-sunken); color: var(--mc-text-primary); }
.kv-add {
align-self: flex-start;
margin-top: 2px;
padding: 5px 8px;
background: transparent;
border: none;
color: var(--mc-primary);
cursor: pointer;
font-size: 12px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 6px;
}
.kv-add:hover { background: var(--mc-primary-bg); }
</style>

View File

@ -0,0 +1,96 @@
<template>
<div class="mcp-icon" :class="paletteClass">
<span
v-if="svgMarkup"
class="mcp-icon-svg"
v-html="svgMarkup"
/>
<span v-else class="mcp-icon-fallback">{{ initials }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getMcpIconSvg } from './icons'
import { catalogInitial } from './catalog'
const props = defineProps<{
/** Display name — used for initials fallback. */
name: string
/** Brand icon basename (matches `assets/icons/mcp/<iconKey>.svg`). */
iconKey?: string
/** Tint variant driven by lastStatus for installed cards;
catalog cards leave it undefined and rely on the default. */
variant?: 'installed-ok' | 'installed-err' | 'installed' | 'brand'
}>()
const svgMarkup = computed(() => getMcpIconSvg(props.iconKey))
const initials = computed(() => catalogInitial(props.name))
const paletteClass = computed(() => {
if (props.variant === 'installed-ok') return 'mcp-icon--installed-ok'
if (props.variant === 'installed-err') return 'mcp-icon--installed-err'
if (props.variant === 'installed') return 'mcp-icon--installed'
return 'mcp-icon--brand'
})
</script>
<style scoped>
.mcp-icon {
width: 38px;
height: 38px;
border-radius: 9px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 13px;
letter-spacing: -0.02em;
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
overflow: hidden;
}
.mcp-icon-svg {
width: 60%;
height: 60%;
display: inline-flex;
align-items: center;
justify-content: center;
}
.mcp-icon-svg :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
.mcp-icon-fallback {
line-height: 1;
}
/* Installed cards — tint background and glyph by status. */
.mcp-icon--installed-ok {
background: var(--mc-primary-bg);
color: var(--mc-primary);
}
.mcp-icon--installed-err {
background: rgba(239, 68, 68, 0.12);
color: var(--mc-danger, #ef4444);
}
.mcp-icon--installed {
background: var(--mc-bg-sunken);
color: var(--mc-text-tertiary);
}
/* Catalog brand bubble neutral surface, brand glyph inherits color.
Light mode keeps a warm cream tone; dark mode flips to elevated. */
.mcp-icon--brand {
background: var(--mc-bg-muted);
color: var(--mc-text-primary);
}
:global(html.dark) .mcp-icon--brand {
background: rgba(255, 255, 255, 0.04);
color: var(--mc-text-primary);
}
</style>

View File

@ -0,0 +1,267 @@
/**
* Curated, locally-bundled directory of one-click MCP servers shown in the
* "Recommended" section of the MCP connections page. Clicking a card opens
* the create-server modal with these fields pre-filled; credential
* placeholders (e.g. `YOUR_API_KEY`) are left intact so the user knows
* what to swap before saving.
*
* All HTTP-based remote MCPs use transport = 'streamable_http' (the
* modern Spring AI default); legacy SSE endpoints can opt into
* 'sse' explicitly. Stdio entries assume the user has `npx` on PATH.
*/
export interface McpCredentialKey {
/** Env var or HTTP header name (e.g. CONTEXT7_API_KEY, Authorization). */
key: string
/** When true, the server is expected to fail without this key. */
required: boolean
}
export type McpCatalogConfig =
| {
transport: 'streamable_http' | 'sse'
url: string
headersJson?: string
}
| {
transport: 'stdio'
command: string
argsJson?: string
envJson?: string
}
export interface McpCatalogEntry {
/** Stable slug — used as the default server name. */
key: string
/** Display name shown on the card. */
name: string
/** One-line capability description. */
description: string
/** Official docs URL (opened from the hover external-link icon). */
docsUrl: string
/** Pre-fill payload for the create-server form. */
config: McpCatalogConfig
/** Which env vars / headers in the config need user-supplied secrets. */
credentialKeys?: McpCredentialKey[]
/** Short visual key for the icon-bubble color class. */
iconKey: string
}
export const mcpCatalog: McpCatalogEntry[] = [
{
key: 'context7',
name: 'Context7',
description: 'Fetch up-to-date library docs and code examples',
docsUrl: 'https://github.com/upstash/context7',
iconKey: 'context7',
config: {
transport: 'streamable_http',
url: 'https://mcp.context7.com/mcp',
headersJson: JSON.stringify({ CONTEXT7_API_KEY: 'YOUR_API_KEY' }, null, 2),
},
credentialKeys: [{ key: 'CONTEXT7_API_KEY', required: false }],
},
{
key: 'github',
name: 'GitHub',
description: 'GitHub Issues, PRs, code search and repository management',
docsUrl: 'https://github.com/github/github-mcp-server',
iconKey: 'github',
config: {
transport: 'stdio',
command: 'npx',
argsJson: JSON.stringify(['-y', '@modelcontextprotocol/server-github']),
envJson: JSON.stringify(
{ GITHUB_PERSONAL_ACCESS_TOKEN: 'YOUR_TOKEN' },
null,
2,
),
},
credentialKeys: [{ key: 'GITHUB_PERSONAL_ACCESS_TOKEN', required: true }],
},
{
key: 'figma',
name: 'Figma',
description: 'Generate diagrams and better code from Figma context',
docsUrl: 'https://help.figma.com/hc/en-us/articles/32132100833559',
iconKey: 'figma',
config: {
transport: 'streamable_http',
url: 'https://mcp.figma.com/mcp',
},
},
{
key: 'linear',
name: 'Linear',
description: 'Manage issues, projects and team workflows',
docsUrl: 'https://linear.app/docs/mcp',
iconKey: 'linear',
config: {
transport: 'streamable_http',
url: 'https://mcp.linear.app/mcp',
},
},
{
key: 'notion',
name: 'Notion',
description: 'Search, update and power workflows across your Notion workspace',
docsUrl: 'https://developers.notion.com/docs/mcp',
iconKey: 'notion',
config: {
transport: 'streamable_http',
url: 'https://mcp.notion.com/mcp',
},
},
{
key: 'slack',
name: 'Slack',
description: 'Send messages, create canvases and fetch Slack data',
docsUrl: 'https://docs.slack.dev/ai/mcp-server',
iconKey: 'slack',
config: {
transport: 'streamable_http',
url: 'https://mcp.slack.com/mcp',
},
},
{
key: 'supabase',
name: 'Supabase',
description: 'Manage databases, authentication and storage',
docsUrl: 'https://supabase.com/docs/guides/getting-started/mcp',
iconKey: 'supabase',
config: {
transport: 'streamable_http',
url: 'https://mcp.supabase.com/mcp',
},
},
{
key: 'vercel',
name: 'Vercel',
description: 'Analyze, debug and manage projects and deployments',
docsUrl: 'https://vercel.com/docs/mcp/vercel-mcp',
iconKey: 'vercel',
config: {
transport: 'streamable_http',
url: 'https://mcp.vercel.com',
},
},
{
key: 'sentry',
name: 'Sentry',
description: 'Search, query and debug errors intelligently',
docsUrl: 'https://docs.sentry.io/product/sentry-mcp/',
iconKey: 'sentry',
config: {
transport: 'streamable_http',
url: 'https://mcp.sentry.dev/mcp',
headersJson: JSON.stringify({ SENTRY_ACCESS_TOKEN: 'YOUR_ACCESS_TOKEN' }, null, 2),
},
credentialKeys: [{ key: 'SENTRY_ACCESS_TOKEN', required: true }],
},
{
key: 'stripe',
name: 'Stripe',
description: 'Payment processing and financial infrastructure tools',
docsUrl: 'https://docs.stripe.com/mcp',
iconKey: 'stripe',
config: {
transport: 'streamable_http',
url: 'https://mcp.stripe.com',
headersJson: JSON.stringify({ STRIPE_SECRET_KEY: 'YOUR_SECRET_KEY' }, null, 2),
},
credentialKeys: [{ key: 'STRIPE_SECRET_KEY', required: true }],
},
{
key: 'atlassian',
name: 'Atlassian',
description: 'Access Jira and Confluence from your agent',
docsUrl: 'https://www.atlassian.com/platform/remote-mcp-server',
iconKey: 'atlassian',
config: {
transport: 'streamable_http',
url: 'https://mcp.atlassian.com/v1/mcp',
},
},
{
key: 'cloudflare',
name: 'Cloudflare',
description: 'Build with compute, storage and AI on Cloudflare',
docsUrl: 'https://developers.cloudflare.com/agents/model-context-protocol/',
iconKey: 'cloudflare',
config: {
transport: 'streamable_http',
url: 'https://bindings.mcp.cloudflare.com/mcp',
},
},
{
key: 'huggingface',
name: 'Hugging Face',
description: 'Access the Hugging Face Hub and thousands of Gradio apps',
docsUrl: 'https://huggingface.co/settings/mcp',
iconKey: 'hugging_face',
config: {
transport: 'streamable_http',
url: 'https://huggingface.co/mcp',
},
},
{
key: 'posthog',
name: 'PostHog',
description: 'Query, analyze and manage your PostHog insights',
docsUrl: 'https://posthog.com/docs/model-context-protocol',
iconKey: 'posthog',
config: {
transport: 'streamable_http',
url: 'https://mcp.posthog.com/mcp',
},
},
{
key: 'playwright',
name: 'Playwright',
description: 'Browser automation with Playwright',
docsUrl: 'https://github.com/microsoft/playwright-mcp',
iconKey: 'playwright',
config: {
transport: 'stdio',
command: 'npx',
argsJson: JSON.stringify(['@playwright/mcp@latest']),
},
},
{
key: 'chrome-devtools',
name: 'Chrome DevTools',
description: 'Browser debugging and performance analysis with Chrome DevTools',
docsUrl: 'https://github.com/ChromeDevTools/chrome-devtools-mcp',
iconKey: 'chrome_devtools',
config: {
transport: 'stdio',
command: 'npx',
argsJson: JSON.stringify(['chrome-devtools-mcp@latest']),
},
},
{
key: 'exa',
name: 'Exa',
description: 'Web search and code context retrieval powered by Exa AI',
docsUrl: 'https://docs.exa.ai/reference/exa-mcp',
iconKey: 'exa',
config: {
transport: 'stdio',
command: 'npx',
argsJson: JSON.stringify(['-y', 'exa-mcp-server', 'tools=web_search_exa,get_code_context_exa']),
envJson: JSON.stringify({ EXA_API_KEY: 'YOUR_API_KEY' }, null, 2),
},
credentialKeys: [{ key: 'EXA_API_KEY', required: true }],
},
]
/** Two-letter abbreviation used in the fallback icon bubble. */
export function catalogInitial(name: string): string {
const trimmed = name.trim()
if (!trimmed) return '?'
// Take leading non-space chunks, prefer first letter of each.
const parts = trimmed.split(/[\s-_/]+/).filter(Boolean)
if (parts.length >= 2) {
return (parts[0].charAt(0) + parts[1].charAt(0)).toUpperCase()
}
return trimmed.slice(0, 2).toUpperCase()
}

View File

@ -0,0 +1,48 @@
/**
* Static MCP brand-icon registry. SVGs are bundled at build time via Vite's
* glob import + `?raw` query, so each icon ships as an inline string and
* can be color-themed with `currentColor` (no external network requests,
* no per-icon component file).
*
* Sources:
* - Brand glyphs adopted from the Simple Icons collection (CC0 1.0).
* - Three-letter convention for filenames: lowercase + underscore.
* The catalog entry's `iconKey` must match the basename.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
const rawSvgs = import.meta.glob('@/assets/icons/mcp/*.svg', {
query: '?raw',
import: 'default',
eager: true,
}) as Record<string, string>
function basename(path: string): string {
return path.split('/').pop()!.replace(/\.\w+$/, '')
}
const ICON_BY_KEY = new Map<string, string>(
Object.entries(rawSvgs).map(([path, raw]) => [basename(path), raw]),
)
/**
* Look up an inline SVG by icon key. Returns the raw SVG markup ready
* for v-html, with width/height stripped and `currentColor` injected so
* the consumer can size it via CSS and tint it with `color`.
*/
export function getMcpIconSvg(iconKey: string | undefined): string | null {
if (!iconKey) return null
const raw = ICON_BY_KEY.get(iconKey)
return raw ? prepareSvg(raw) : null
}
function prepareSvg(svg: string): string {
return svg
// Strip fixed sizing so the consumer's container governs the icon size.
.replace(/\bwidth="[^"]*"/g, '')
.replace(/\bheight="[^"]*"/g, '')
// Drop any embedded <style> blocks that might re-introduce sizing.
.replace(/<style>[\s\S]*?<\/style>/g, '')
// Tint via currentColor so the parent's `color` cascades into the glyph.
.replace('<svg ', '<svg fill="currentColor" preserveAspectRatio="xMidYMid meet" ')
}

View File

@ -0,0 +1,67 @@
/**
* Shape returned by GET /api/v1/mcp/servers mirrors the McpServerEntity
* column set. Kept here (rather than in the API module) so the MCP page
* components can import a single domain type without pulling axios with it.
*/
export interface McpServer {
id: number
name: string
description: string
transport: 'stdio' | 'sse' | 'streamable_http'
url: string
headersJson: string
command: string
argsJson: string
envJson: string
cwd: string
enabled: boolean
connectTimeoutSeconds: number
readTimeoutSeconds: number
lastStatus: 'connected' | 'disconnected' | 'error' | string
lastError: string
lastConnectedTime: string
toolCount: number
builtin: boolean
}
/** Result of POST /api/v1/mcp/servers/{id}/test. */
export interface McpTestResult {
success: boolean
message: string
toolCount: number
latencyMs: number
discoveredTools: string[]
}
/** Form payload — superset of the create/edit body. */
export interface McpServerForm {
name: string
description: string
transport: 'stdio' | 'sse' | 'streamable_http'
url: string
headersJson: string
command: string
argsJson: string
envJson: string
cwd: string
connectTimeoutSeconds: number
readTimeoutSeconds: number
enabled: boolean
}
export function emptyMcpForm(): McpServerForm {
return {
name: '',
description: '',
transport: 'stdio',
url: '',
headersJson: '',
command: '',
argsJson: '',
envJson: '',
cwd: '',
connectTimeoutSeconds: 30,
readTimeoutSeconds: 30,
enabled: true,
}
}