release(secrets-mcp): 0.6.0 - local gateway onboarding and target_exec
This commit is contained in:
157
crates/secrets-mcp-local/src/server.rs
Normal file
157
crates/secrets-mcp-local/src/server.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user