Files
secrets/crates/secrets-mcp-local/src/server.rs
voson cb5865b958
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m54s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 2m16s
release(secrets-mcp): 0.6.0 - local gateway onboarding and target_exec
2026-04-12 22:47:46 +08:00

158 lines
5.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::sync::Arc;
use axum::Router;
use axum::extract::State;
use axum::response::{Html, IntoResponse};
use axum::routing::{get, post};
use crate::cache::SharedCache;
use crate::config::LocalConfig;
use crate::remote::RemoteClient;
#[derive(Clone)]
pub struct AppState {
pub config: LocalConfig,
pub cache: SharedCache,
pub remote: Arc<RemoteClient>,
}
async fn index(State(state): State<AppState>) -> impl IntoResponse {
Html(format!(
r#"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>secrets-mcp-local onboarding</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 920px; margin: 24px auto; padding: 0 16px; line-height: 1.5; }}
code, pre {{ background: #f6f8fa; border-radius: 6px; }}
code {{ padding: 2px 6px; }}
pre {{ padding: 12px; overflow-x: auto; }}
.card {{ border: 1px solid #d0d7de; border-radius: 12px; padding: 16px; margin: 16px 0; }}
.row {{ display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }}
button, a.button {{ border: 1px solid #1f2328; background: #1f2328; color: white; padding: 8px 14px; border-radius: 8px; text-decoration: none; cursor: pointer; }}
a.secondary, button.secondary {{ background: white; color: #1f2328; }}
iframe {{ width: 100%; min-height: 420px; border: 1px solid #d0d7de; border-radius: 12px; }}
.muted {{ color: #57606a; }}
</style>
</head>
<body>
<h1>secrets-mcp-local</h1>
<p class="muted">本地 MCP 地址:<code>http://{bind}/mcp</code></p>
<p class="muted">远端服务地址:<code>{remote}</code></p>
<div class="card">
<h2>当前状态</h2>
<pre id="status">loading...</pre>
<div class="row">
<button id="start-bind">开始绑定</button>
<button id="poll-bind" class="secondary">检查授权结果</button>
<a class="button secondary" href="/unlock" target="_blank" rel="noreferrer">打开解锁页</a>
<button id="refresh" class="secondary">刷新状态</button>
</div>
</div>
<div class="card">
<h2>步骤 1远端授权</h2>
<p id="approve-hint" class="muted">点击“开始绑定”后,这里会显示授权地址。</p>
<div id="approve-actions" class="row"></div>
</div>
<div class="card">
<h2>步骤 2本地解锁</h2>
<p class="muted">授权完成后,本页会自动切换到解锁阶段。你也可以直接在下方完成解锁。</p>
<iframe id="unlock-frame" src="/unlock"></iframe>
</div>
<div class="card">
<h2>接入 Cursor</h2>
<p>把 MCP 地址配置为 <code>http://{bind}/mcp</code>。在未就绪时AI 只会看到 bootstrap 工具;完成授权和解锁后会自动暴露业务工具。</p>
</div>
<script>
const statusEl = document.getElementById('status');
const approveHint = document.getElementById('approve-hint');
const approveActions = document.getElementById('approve-actions');
const unlockFrame = document.getElementById('unlock-frame');
function renderApprove(info) {{
approveActions.innerHTML = '';
if (!info?.approve_url) return;
approveHint.textContent = '请先在浏览器完成远端授权,然后回到这里等待自动进入解锁状态。';
const link = document.createElement('a');
link.href = info.approve_url;
link.target = '_blank';
link.rel = 'noreferrer';
link.className = 'button';
link.textContent = '打开远端授权页';
approveActions.appendChild(link);
}}
async function refreshStatus() {{
const res = await fetch('/local/status');
const data = await res.json();
statusEl.textContent = JSON.stringify(data, null, 2);
if (data.pending_bind) renderApprove(data.pending_bind);
if (data.state === 'ready') {{
approveHint.textContent = '本地 MCP 已 ready可以返回 Cursor 正常使用。';
}} else if (data.state === 'pendingUnlock') {{
approveHint.textContent = '远端授权已完成,继续在下方完成本地解锁。';
}}
return data;
}}
async function startBind() {{
const res = await fetch('/local/bind/start', {{ method: 'POST' }});
const data = await res.json();
statusEl.textContent = JSON.stringify(data, null, 2);
renderApprove(data);
}}
async function pollBind() {{
const res = await fetch('/local/bind/exchange', {{
method: 'POST',
headers: {{ 'content-type': 'application/json' }},
body: JSON.stringify({{}})
}});
const data = await res.json();
statusEl.textContent = JSON.stringify(data, null, 2);
await refreshStatus();
if (res.ok && data.status === 'bound') {{
unlockFrame.src = '/unlock';
}}
}}
document.getElementById('start-bind').onclick = startBind;
document.getElementById('poll-bind').onclick = pollBind;
document.getElementById('refresh').onclick = refreshStatus;
window.addEventListener('message', (event) => {{
if (event?.data?.type === 'secrets-mcp-local-ready') refreshStatus();
}});
refreshStatus();
setInterval(refreshStatus, 3000);
</script>
</body>
</html>"#,
bind = state.config.bind,
remote = state.config.remote_base_url,
))
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/", get(index))
.route("/mcp", axum::routing::any(crate::mcp::handle_mcp))
.route("/local/bind/start", post(crate::bind::bind_start))
.route("/local/bind/exchange", post(crate::bind::bind_exchange))
.route("/local/unbind", post(crate::bind::unbind))
.route("/unlock", get(crate::unlock::unlock_page))
.route(
"/local/unlock/complete",
post(crate::unlock::unlock_complete),
)
.route("/local/lock", post(crate::unlock::lock))
.route("/local/status", get(crate::unlock::status))
.layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
.with_state(state)
}