feat(mcp): 客户端配置代码块添加语法高亮
将配置片段 DTO 从扁平字段重构为 snippets Vec(含 content/content_html), 高亮在服务端经 highlight_code 完成(JSON 配置用 json 语法,CLI 用 bash), 前端以 dangerous_inner_html 渲染并包 .md-content 匹配 highlight.css 作用域。 补 json/bash 语法解析测试,确认两者均产出彩色 span 而非纯文本回退。
This commit is contained in:
parent
53376a0373
commit
1b772f4534
@ -262,50 +262,66 @@ fn row_to_mcp_token_meta(row: &tokio_postgres::Row) -> McpToken {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 4 种客户端配置 + CLI 一行命令的序列化 DTO。
|
/// 单个客户端配置片段:标题 + 原始文本(供复制)+ syntect 高亮 HTML(供展示)。
|
||||||
///
|
///
|
||||||
/// 由 `get_mcp_client_configs` server fn 返回。`ClientConfigs`(在 `src/mcp/config.rs`)
|
/// 高亮 HTML 由 `crate::highlight::server::highlight_code` 生成(spaced CSS class 风格,
|
||||||
/// 是 server-only(`mcp` 模块整体 `#[cfg(feature = "server")]` 门控),这里复制字段
|
/// 配合 `public/highlight.css`);前端需将其置于 `.md-content pre code` 作用域下,
|
||||||
/// 为可两端共享的 DTO,让 WASM 前端能经 server fn 拿到配置字符串。
|
/// 否则高亮 CSS 选择器不匹配(见 `src/bin/generate_highlight_css.rs` 的 base 重写)。
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct McpClientConfigs {
|
pub struct McpConfigSnippet {
|
||||||
/// Claude Code(`.mcp.json` / `~/.claude.json`)。`type: "http"`(非 `streamable-http`)。
|
/// 显示标题(含客户端名与目标文件路径)。
|
||||||
pub claude_code_json: String,
|
pub title: String,
|
||||||
/// Cursor(`~/.cursor/mcp.json`)。不带 `type` 字段,按 URL 自动识别 streamable-http。
|
/// 原始配置文本(供「复制」按钮复制,未高亮)。
|
||||||
pub cursor_json: String,
|
pub content: String,
|
||||||
/// Cline(`cline_mcp_settings.json`)。
|
/// syntect 高亮后的 HTML(`<span>` 序列,无 `<pre>/<code>` 外壳)。
|
||||||
pub cline_json: String,
|
pub content_html: String,
|
||||||
/// Oh-My-Pi(`~/.pi/agent/mcp.json` / `.pi/mcp.json`)。字段名 `transport`,非 `type`。
|
|
||||||
pub omp_json: String,
|
|
||||||
/// 通用原始 JSON(单 server entry)。
|
|
||||||
pub generic_json: String,
|
|
||||||
/// Claude Code CLI 一行命令。
|
|
||||||
pub claude_cli: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 根据明文令牌生成 4 种客户端配置 + CLI 一行命令。
|
/// 各客户端 MCP 配置片段集合。
|
||||||
///
|
///
|
||||||
/// 配置生成在服务端完成(`crate::mcp::config` 是 server-only 模块),返回给前端展示。
|
/// 由 `get_mcp_client_configs` server fn 返回。`ClientConfigs`(在 `src/mcp/config.rs`)
|
||||||
/// `APP_BASE_URL` 环境变量也只在服务端读取。仅 admin。
|
/// 是 server-only(`mcp` 模块整体 `#[cfg(feature = "server")]` 门控);这里把每个片段的
|
||||||
|
/// 原始文本与高亮 HTML 打包为可两端共享的 DTO,让 WASM 前端单次请求即可渲染带高亮的配置块。
|
||||||
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct McpClientConfigs {
|
||||||
|
/// 有序配置片段(标题、原始文本、高亮 HTML)。
|
||||||
|
pub snippets: Vec<McpConfigSnippet>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据明文令牌生成各客户端配置片段(含 syntect 高亮 HTML)。
|
||||||
|
///
|
||||||
|
/// 配置生成与高亮均在服务端完成(`crate::mcp::config` / `crate::highlight` 均为
|
||||||
|
/// server-only),返回给前端展示。`APP_BASE_URL` 环境变量也只在服务端读取。仅 admin。
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn get_mcp_client_configs(token: String) -> Result<McpClientConfigs, ServerFnError> {
|
pub async fn get_mcp_client_configs(token: String) -> Result<McpClientConfigs, ServerFnError> {
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
{
|
{
|
||||||
use crate::api::auth::get_current_admin_user;
|
use crate::api::auth::get_current_admin_user;
|
||||||
|
use crate::highlight::server::highlight_code;
|
||||||
|
|
||||||
let _admin = get_current_admin_user().await?;
|
let _admin = get_current_admin_user().await?;
|
||||||
let configs = crate::mcp::config::generate_client_configs(
|
let c = crate::mcp::config::generate_client_configs(
|
||||||
&crate::mcp::config::base_url_from_env(),
|
&crate::mcp::config::base_url_from_env(),
|
||||||
&token,
|
&token,
|
||||||
);
|
);
|
||||||
Ok(McpClientConfigs {
|
// (标题, 内容, 语言):JSON 配置用 json 语法高亮,CLI 一行命令用 bash。
|
||||||
claude_code_json: configs.claude_code_json,
|
let entries: [(&str, String, &str); 6] = [
|
||||||
cursor_json: configs.cursor_json,
|
("Claude Code(.mcp.json / ~/.claude.json)", c.claude_code_json, "json"),
|
||||||
cline_json: configs.cline_json,
|
("Cursor(~/.cursor/mcp.json)", c.cursor_json, "json"),
|
||||||
omp_json: configs.omp_json,
|
("Cline(cline_mcp_settings.json)", c.cline_json, "json"),
|
||||||
generic_json: configs.generic_json,
|
("Oh-My-Pi(~/.pi/agent/mcp.json 或 .pi/mcp.json)", c.omp_json, "json"),
|
||||||
claude_cli: configs.claude_cli,
|
("通用(单 server entry)", c.generic_json, "json"),
|
||||||
|
("Claude Code CLI", c.claude_cli, "bash"),
|
||||||
|
];
|
||||||
|
let snippets = entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(title, content, lang)| McpConfigSnippet {
|
||||||
|
title: title.to_string(),
|
||||||
|
content_html: highlight_code(&content, Some(lang)),
|
||||||
|
content,
|
||||||
})
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(McpClientConfigs { snippets })
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
unreachable!()
|
unreachable!()
|
||||||
|
|||||||
@ -508,4 +508,36 @@ const message = ref('Hello Vue!')
|
|||||||
let by_ts = highlight_code(code, Some("ts"));
|
let by_ts = highlight_code(code, Some("ts"));
|
||||||
assert_eq!(result, by_ts);
|
assert_eq!(result, by_ts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_json_produces_spans() {
|
||||||
|
// MCP 客户端配置页的 JSON 片段经此路径高亮;验证 json 解析为彩色语法而非纯文本。
|
||||||
|
let code = r#"{"mcpServers": {"yggdrasil": {"type": "http"}}}"#;
|
||||||
|
let result = highlight_code(code, Some("json"));
|
||||||
|
assert!(
|
||||||
|
result.contains("<span"),
|
||||||
|
"json 应触发语法高亮, got: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!result.contains(r#"<span class="text plain">"#),
|
||||||
|
"json 不应回退纯文本: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_bash_alias_produces_spans() {
|
||||||
|
// MCP 客户端配置页的 CLI 一行命令经 bash 别名高亮。
|
||||||
|
let code = "claude mcp add --transport http yggdrasil https://example.com/mcp";
|
||||||
|
let result = highlight_code(code, Some("bash"));
|
||||||
|
assert!(
|
||||||
|
result.contains("<span"),
|
||||||
|
"bash 别名应触发语法高亮, got: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
// bash 别名与直接传 sh 的输出须一致(别名表 "bash" -> "sh")。
|
||||||
|
let by_sh = highlight_code(code, Some("sh"));
|
||||||
|
assert_eq!(result, by_sh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@ use dioxus::prelude::*;
|
|||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::api::mcp_tokens::{
|
use crate::api::mcp_tokens::{
|
||||||
create_mcp_token, get_mcp_client_configs, list_mcp_tokens, reveal_mcp_token, revoke_mcp_token,
|
create_mcp_token, get_mcp_client_configs, list_mcp_tokens, reveal_mcp_token, revoke_mcp_token,
|
||||||
McpClientConfigs, TokenLifetime,
|
McpClientConfigs, McpConfigSnippet, TokenLifetime,
|
||||||
};
|
};
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::components::forms::{FormSelect, INPUT_CLASS};
|
use crate::components::forms::{FormSelect, INPUT_CLASS};
|
||||||
@ -503,12 +503,9 @@ fn ConfigCard() -> Element {
|
|||||||
p { class: "text-sm text-[var(--color-paper-tertiary)] py-4 text-center", "生成配置中…" }
|
p { class: "text-sm text-[var(--color-paper-tertiary)] py-4 text-center", "生成配置中…" }
|
||||||
} else if let Some(c) = configs() {
|
} else if let Some(c) = configs() {
|
||||||
div { class: "flex flex-col gap-4",
|
div { class: "flex flex-col gap-4",
|
||||||
ConfigSnippet { title: "Claude Code(.mcp.json / ~/.claude.json)".to_string(), content: c.claude_code_json.clone() }
|
for snippet in c.snippets.iter() {
|
||||||
ConfigSnippet { title: "Cursor(~/.cursor/mcp.json)".to_string(), content: c.cursor_json.clone() }
|
ConfigSnippet { key: "{snippet.title}", snippet: snippet.clone() }
|
||||||
ConfigSnippet { title: "Cline(cline_mcp_settings.json)".to_string(), content: c.cline_json.clone() }
|
}
|
||||||
ConfigSnippet { title: "Oh-My-Pi(~/.pi/agent/mcp.json 或 .pi/mcp.json)".to_string(), content: c.omp_json.clone() }
|
|
||||||
ConfigSnippet { title: "通用(单 server entry)".to_string(), content: c.generic_json.clone() }
|
|
||||||
ConfigSnippet { title: "Claude Code CLI".to_string(), content: c.claude_cli.clone() }
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
p { class: "text-sm text-[var(--color-paper-tertiary)] py-4 text-center",
|
p { class: "text-sm text-[var(--color-paper-tertiary)] py-4 text-center",
|
||||||
@ -522,17 +519,18 @@ fn ConfigCard() -> Element {
|
|||||||
/// 单个配置片段卡片(标题 + 代码块 + 复制按钮)。
|
/// 单个配置片段卡片(标题 + 代码块 + 复制按钮)。
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
#[component]
|
#[component]
|
||||||
fn ConfigSnippet(title: String, content: String) -> Element {
|
fn ConfigSnippet(snippet: McpConfigSnippet) -> Element {
|
||||||
let state: McpPageState = use_context();
|
let state: McpPageState = use_context();
|
||||||
let mut toast = state.toast;
|
let mut toast = state.toast;
|
||||||
|
let title = snippet.title.clone();
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "flex flex-col gap-2",
|
div { class: "flex flex-col gap-2",
|
||||||
div { class: "flex items-center justify-between",
|
div { class: "flex items-center justify-between",
|
||||||
span { class: "text-sm font-medium text-[var(--color-paper-primary)]", "{title}" }
|
span { class: "text-sm font-medium text-[var(--color-paper-primary)]", "{snippet.title}" }
|
||||||
button {
|
button {
|
||||||
class: "{BTN_PRIMARY_SM}",
|
class: "{BTN_PRIMARY_SM}",
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
let cc = content.clone();
|
let cc = snippet.content.clone();
|
||||||
let tt = title.clone();
|
let tt = title.clone();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
copy_clipboard_wasm(&cc).await;
|
copy_clipboard_wasm(&cc).await;
|
||||||
@ -542,9 +540,13 @@ fn ConfigSnippet(title: String, content: String) -> Element {
|
|||||||
"复制"
|
"复制"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// .md-content 是 highlight.css 的作用域钩子(.md-content pre code …);
|
||||||
|
// 仅包裹 pre,不触及标题/按钮,避免 prose 排式泄漏。
|
||||||
|
div { class: "md-content",
|
||||||
pre {
|
pre {
|
||||||
class: "bg-[var(--color-paper-code-bg)] text-[var(--color-paper-primary)] rounded-lg p-3 text-xs overflow-x-auto font-mono",
|
class: "bg-[var(--color-paper-code-bg)] text-[var(--color-paper-primary)] rounded-lg p-3 text-xs overflow-x-auto font-mono",
|
||||||
code { "{content}" }
|
code { dangerous_inner_html: "{snippet.content_html}" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user