Skip to main content

yggdrasil/pages/admin/
mcp.rs

1//! 管理后台「MCP 接入」页面(`/admin/mcp`)。
2//!
3//! 为 Codex、Claude Code、Cursor、Cline、Oh-My-Pi、OpenCode 等 AI 客户端签发 bearer
4//! 令牌。页面分两块:
5//! - 一句话接入:选一个有效令牌,复制带服务地址与令牌的接入指令,粘贴给 Agent。
6//! - 已签发的令牌:文章权限、笔记授权、有效期与最近使用;可查看接入配置、编辑权限、
7//!   吊销(确认对话框)。
8//!
9//! 「新建令牌」打开创建对话框;创建成功后进入令牌对话框:明文默认遮罩,可显示、
10//! 复制,并按客户端给出已填好地址与令牌的配置片段。配置由服务端生成
11//! (`get_mcp_client_configs`,`APP_BASE_URL` 只在服务端可读)。样式见
12//! styles/pages/admin-mcp.css。
13//!
14//! 令牌列表只在浏览器里请求(`Effect` + `spawn_local` 写镜像信号,带世代号防竞态):
15//! 列表会刷新,且位于外层 `<Suspense>` 内,不能用 `Resource`。
16
17mod configs;
18mod token_editor;
19mod tokens;
20
21use leptos::prelude::*;
22use leptos::task::spawn_local;
23use leptos_router::{lazy_route, LazyRoute};
24
25use crate::api::mcp_tokens::list_mcp_tokens;
26use crate::components::admin_layout::AdminPageHead;
27use crate::components::forms::{FormSelect, FORM_SELECT_COMPACT_CLASS};
28use crate::components::ui::{button_class, use_toast, ButtonSize, ButtonVariant, Icon, IconName};
29use crate::models::mcp_token::{McpTokenSummary, TokenScope};
30use configs::{copy_agent_prompt, TokenDialog};
31use token_editor::{CreateTokenDialog, EditPermissionsDialog};
32use tokens::TokenTable;
33
34/// `/admin/mcp` 的懒加载路由。令牌数据只在客户端请求,`data()` 无需创建资源。
35pub struct McpRoute;
36
37#[lazy_route]
38impl LazyRoute for McpRoute {
39    fn data() -> Self {
40        Self
41    }
42
43    fn view(_this: Self) -> AnyView {
44        view! { <Mcp/> }.into_any()
45    }
46}
47
48/// 文章权限的说明。
49pub(super) fn scope_desc(scope: TokenScope) -> &'static str {
50    match scope {
51        TokenScope::Read => "仅查询已发布文章",
52        TokenScope::Write => "read + 文章、评论、标签、媒体的增删改",
53        TokenScope::Admin => "write + 站点设置与代码运行器",
54    }
55}
56
57/// 笔记授权的一行描述。
58pub(super) fn notes_desc(token: &McpTokenSummary) -> String {
59    let n = &token.notes;
60    if !n.read {
61        return "未授权".into();
62    }
63    let what = if n.write {
64        "读写、公开与收录"
65    } else {
66        "只读"
67    };
68    match &n.notebook_ids {
69        Some(ids) => format!("{what} · {} 个笔记本", ids.len()),
70        None => format!("{what} · 全部笔记本"),
71    }
72}
73
74/// 令牌当前可用:未吊销、未过期。
75pub(super) fn is_active(token: &McpTokenSummary) -> bool {
76    token.revoked_at.is_none() && token.expires_at.is_none_or(|e| e > chrono::Utc::now())
77}
78
79/// 令牌对话框里要展示的内容。
80#[derive(Clone, PartialEq)]
81pub(super) struct OpenToken {
82    pub summary: McpTokenSummary,
83    pub plaintext: String,
84    /// 刚创建(标题与提示不同)。
85    pub created: bool,
86}
87
88/// 管理后台 MCP 接入页面。
89#[component]
90pub fn Mcp() -> impl IntoView {
91    let toast = use_toast();
92    let tokens = RwSignal::new(None::<Vec<McpTokenSummary>>);
93    let failed = RwSignal::new(false);
94    let reload = RwSignal::new(0_u32);
95    let creating = RwSignal::new(false);
96    let editing = RwSignal::new(None::<McpTokenSummary>);
97    let opened = RwSignal::new(None::<OpenToken>);
98    let prompt_token = RwSignal::new(None::<String>);
99    let copying = RwSignal::new(false);
100    let copied = RwSignal::new(false);
101
102    // `reload` 变化时重新取列表;刷新期间保留旧数据。仅客户端执行(Effect 不在 SSR 运行)。
103    let fetch_generation = StoredValue::new(0_u64);
104    Effect::new(move |_| {
105        reload.track();
106        let Some(current) = fetch_generation.try_update_value(|g| {
107            *g += 1;
108            *g
109        }) else {
110            return;
111        };
112        spawn_local(async move {
113            let result = list_mcp_tokens().await;
114            if fetch_generation.try_get_value() != Some(current) {
115                return;
116            }
117            match result {
118                Ok(list) => {
119                    let _ = tokens.try_set(Some(list));
120                    let _ = failed.try_set(false);
121                }
122                Err(_) => {
123                    let _ = failed.try_set(true);
124                }
125            }
126        });
127    });
128
129    let active = Memo::new(move |_| {
130        tokens.with(|list| {
131            list.iter()
132                .flatten()
133                .filter(|t| is_active(t))
134                .cloned()
135                .collect::<Vec<McpTokenSummary>>()
136        })
137    });
138    // 一句话接入默认用最新的有效令牌。
139    let chosen = Memo::new(move |_| {
140        active.with(|active| {
141            prompt_token
142                .get()
143                .filter(|id| active.iter().any(|t| &t.id == id))
144                .or_else(|| active.first().map(|t| t.id.clone()))
145        })
146    });
147    let options = Signal::derive(move || {
148        active.with(|active| {
149            active
150                .iter()
151                .map(|t| (t.id.clone(), t.name.clone()))
152                .collect::<Vec<(String, String)>>()
153        })
154    });
155
156    // 仅在“有无可用令牌”切换时重建下拉,避免选项变化时收起已展开的面板。
157    let has_options = Memo::new(move |_| !options.with(Vec::is_empty));
158
159    let copy_prompt = move |_| {
160        let Some(id) = chosen.get_untracked() else {
161            return;
162        };
163        copying.set(true);
164        spawn_local(async move {
165            match copy_agent_prompt(id).await {
166                Ok(()) => {
167                    let _ = copied.try_set(true);
168                    toast.success("已复制接入指令,粘贴给 Agent 即可");
169                    crate::utils::time::sleep_ms(1600).await;
170                    let _ = copied.try_set(false);
171                }
172                Err(e) => toast.error(e),
173            }
174            let _ = copying.try_set(false);
175        });
176    };
177
178    view! {
179        <div class="admin-mcp">
180            <AdminPageHead
181                title="MCP 接入"
182                actions=move || {
183                    view! {
184                        <button
185                            class=button_class(ButtonVariant::Primary, ButtonSize::Md)
186                            type="button"
187                            on:click=move |_| creating.set(true)
188                        >
189                            <Icon name=IconName::Plus size=16/>
190                            "新建令牌"
191                        </button>
192                    }
193                }
194            >
195                "为 Codex、Claude Code、Cursor、Cline、Oh-My-Pi、OpenCode 等 AI 客户端签发访问令牌。"
196            </AdminPageHead>
197
198            <section class="admin-panel mcp-oneline" aria-labelledby="mcp-oneline-h">
199                <div class="mcp-oneline-head">
200                    <h2 id="mcp-oneline-h">"一句话接入"</h2>
201                    <span>"复制时自动附上服务地址和所选令牌"</span>
202                </div>
203                <p class="mcp-oneline-text">
204                    "请为当前客户端接入 Yggdrasil MCP,保留已有配置,并验证连接与工具列表。"
205                </p>
206                <div class="mcp-oneline-actions">
207                    {move || {
208                        if !has_options.get() {
209                            view! { <span class="mcp-muted">"还没有可用的令牌,先新建一个。"</span> }
210                                .into_any()
211                        } else {
212                            view! {
213                                <span class="mcp-muted">"使用令牌"</span>
214                                <FormSelect
215                                    id="mcp-prompt-token"
216                                    value=Signal::derive(move || chosen.get().unwrap_or_default())
217                                    options=options
218                                    trigger_class=FORM_SELECT_COMPACT_CLASS
219                                    aria_label="使用令牌"
220                                    onchange=move |id: String| prompt_token.set(Some(id))
221                                />
222                            }
223                                .into_any()
224                        }
225                    }}
226                    <button
227                        class=format!(
228                            "{} mcp-copy-prompt",
229                            button_class(ButtonVariant::Primary, ButtonSize::Sm),
230                        )
231                        type="button"
232                        data-copied=move || copied.get().then_some("true")
233                        disabled=move || chosen.get().is_none() || copying.get()
234                        on:click=copy_prompt
235                    >
236                        {move || {
237                            view! {
238                                <Icon
239                                    name=if copied.get() { IconName::Check } else { IconName::Copy }
240                                    size=14
241                                />
242                            }
243                        }}
244                        {move || {
245                            if copied.get() {
246                                "已复制"
247                            } else if copying.get() {
248                                "准备中…"
249                            } else {
250                                "复制给 Agent"
251                            }
252                        }}
253                    </button>
254                </div>
255            </section>
256
257            <TokenTable
258                tokens=tokens
259                failed=failed
260                on_retry=move |_| reload.update(|n| *n += 1)
261                on_open=move |t: OpenToken| opened.set(Some(t))
262                on_edit=move |t: McpTokenSummary| editing.set(Some(t))
263                on_changed=move |_| reload.update(|n| *n += 1)
264            />
265        </div>
266
267        {move || {
268            creating
269                .get()
270                .then(|| {
271                    view! {
272                        <CreateTokenDialog
273                            on_close=move |_| creating.set(false)
274                            on_created=move |t: OpenToken| {
275                                creating.set(false);
276                                reload.update(|n| *n += 1);
277                                opened.set(Some(t));
278                            }
279                        />
280                    }
281                })
282        }}
283        {move || {
284            editing
285                .get()
286                .map(|token| {
287                    view! {
288                        <EditPermissionsDialog
289                            token=token
290                            on_close=move |_| editing.set(None)
291                            on_saved=move |_| {
292                                editing.set(None);
293                                reload.update(|n| *n += 1);
294                            }
295                        />
296                    }
297                })
298        }}
299        {move || {
300            opened
301                .get()
302                .map(|token| {
303                    view! { <TokenDialog token=token on_close=move |_| opened.set(None)/> }
304                })
305        }}
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::models::mcp_token::NoteGrant;
313
314    fn token(notes: NoteGrant) -> McpTokenSummary {
315        McpTokenSummary {
316            id: "t".into(),
317            name: "t".into(),
318            scope: TokenScope::Read,
319            notes,
320            created_at: chrono::Utc::now(),
321            expires_at: None,
322            last_used_at: None,
323            revoked_at: None,
324        }
325    }
326
327    #[test]
328    fn expired_or_revoked_tokens_are_inactive() {
329        let mut t = token(NoteGrant::default());
330        assert!(is_active(&t));
331        t.expires_at = Some(chrono::Utc::now() - chrono::Duration::hours(1));
332        assert!(!is_active(&t));
333        t.expires_at = None;
334        t.revoked_at = Some(chrono::Utc::now());
335        assert!(!is_active(&t));
336    }
337}