feat(ui/mcp): redesign MCP connections page with componentized layout
20
README.md
@ -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
|
||||
|
||||
|
||||
20
README_zh.md
@ -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 绑定到源 chunk,JSON 输出 + 可选 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 包 · MCP(stdio / 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.0(2026-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 上游集成。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
|
||||
@ -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 |
@ -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 |
@ -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 |
@ -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 |
1
mateclaw-ui/src/assets/icons/mcp/atlassian.svg
Normal 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 |
1
mateclaw-ui/src/assets/icons/mcp/chrome_devtools.svg
Normal 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 |
1
mateclaw-ui/src/assets/icons/mcp/cloudflare.svg
Normal 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 |
7
mateclaw-ui/src/assets/icons/mcp/context7.svg
Normal 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 |
1
mateclaw-ui/src/assets/icons/mcp/figma.svg
Normal 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 |
1
mateclaw-ui/src/assets/icons/mcp/github.svg
Normal 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 |
1
mateclaw-ui/src/assets/icons/mcp/hugging_face.svg
Normal 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 |
1
mateclaw-ui/src/assets/icons/mcp/linear.svg
Normal 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 |
1
mateclaw-ui/src/assets/icons/mcp/notion.svg
Normal 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 |
1
mateclaw-ui/src/assets/icons/mcp/playwright.svg
Normal 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 |
1
mateclaw-ui/src/assets/icons/mcp/posthog.svg
Normal 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 |
1
mateclaw-ui/src/assets/icons/mcp/sentry.svg
Normal 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 |
1
mateclaw-ui/src/assets/icons/mcp/slack.svg
Normal 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 |
1
mateclaw-ui/src/assets/icons/mcp/stripe.svg
Normal 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 |
1
mateclaw-ui/src/assets/icons/mcp/supabase.svg
Normal 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 |
1
mateclaw-ui/src/assets/icons/mcp/vercel.svg
Normal 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 |
189
mateclaw-ui/src/composables/useMcpServers.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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)',
|
||||
|
||||
@ -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: '读取超时 (秒)',
|
||||
|
||||
@ -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">×</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 }) }} ·
|
||||
{{ 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">×</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>
|
||||
|
||||
397
mateclaw-ui/src/views/mcp/McpCard.vue
Normal 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>
|
||||
77
mateclaw-ui/src/views/mcp/McpEmptyState.vue
Normal 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>
|
||||
450
mateclaw-ui/src/views/mcp/McpFormModal.vue
Normal 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')">×</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>
|
||||
265
mateclaw-ui/src/views/mcp/McpKvEditor.vue
Normal 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>
|
||||
96
mateclaw-ui/src/views/mcp/McpServerIcon.vue
Normal 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>
|
||||
267
mateclaw-ui/src/views/mcp/catalog.ts
Normal 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()
|
||||
}
|
||||
48
mateclaw-ui/src/views/mcp/icons.ts
Normal 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" ')
|
||||
}
|
||||
67
mateclaw-ui/src/views/mcp/types.ts
Normal 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,
|
||||
}
|
||||
}
|
||||