158 lines
5.7 KiB
Rust
158 lines
5.7 KiB
Rust
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)
|
||
}
|