yggdrasil/pages/admin/
mcp.rs1mod 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
34pub 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
48pub(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
57pub(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
74pub(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#[derive(Clone, PartialEq)]
81pub(super) struct OpenToken {
82 pub summary: McpTokenSummary,
83 pub plaintext: String,
84 pub created: bool,
86}
87
88#[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 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 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 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}