yuki / okibi
焚き火の仲間だけの自前Gitホスティング(このサイト自身)
b515b02065092542f7c7fde32c88a61056cc624d
yuki
1781058964
feat: MCP server (/mcp) + 設定にMCP/git接続コマンド + MCP機能カード
---
src/auth.rs | 30 ++++++++
src/main.rs | 2 +
src/mcp.rs | 226 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/web.rs | 13 +++-
4 files changed, 270 insertions(+), 1 deletion(-)
diff --git a/src/auth.rs b/src/auth.rs
index e056410..683cfb1 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -212,6 +212,36 @@ pub async fn create_pat(db: &Db, user_id: i64, name: &str) -> anyhow::Result<Str
Ok(token)
}
+/// Resolve a user from a bare PAT (used by the MCP Bearer auth).
+pub async fn user_by_pat(db: &Db, token: &str) -> Option<User> {
+ let h = sha256_hex(token);
+ let uid = sqlx::query_scalar::<_, i64>("SELECT user_id FROM pats WHERE token_hash = ?")
+ .bind(&h)
+ .fetch_optional(db)
+ .await
+ .ok()
+ .flatten()?;
+ let _ = sqlx::query("UPDATE pats SET last_used = ? WHERE token_hash = ?")
+ .bind(now())
+ .bind(&h)
+ .execute(db)
+ .await;
+ sqlx::query_as::<_, User>(
+ "SELECT id, handle, email, display_name, is_admin FROM users WHERE id = ?",
+ )
+ .bind(uid)
+ .fetch_optional(db)
+ .await
+ .ok()
+ .flatten()
+}
+
+/// Extract a Bearer token from the Authorization header.
+pub fn bearer(headers: &HeaderMap) -> Option<String> {
+ let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
+ raw.strip_prefix("Bearer ").map(|s| s.trim().to_string())
+}
+
/// Verify HTTP basic auth (handle + PAT) for git operations.
pub async fn verify_pat(db: &Db, handle: &str, token: &str) -> Option<User> {
let user = user_by_handle(db, handle).await?;
diff --git a/src/main.rs b/src/main.rs
index d529c6b..7220638 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,6 +4,7 @@ mod config;
mod db;
mod email;
mod git_http;
+mod mcp;
mod repos;
mod util;
mod web;
@@ -43,6 +44,7 @@ async fn main() -> anyhow::Result<()> {
let app = Router::new()
.route("/", get(web::index))
.route("/about", get(web::about))
+ .route("/mcp", get(mcp::mcp_info).post(mcp::mcp_post))
.route("/healthz", get(healthz))
.route("/login", get(web::login_form).post(web::login_submit))
.route("/auth/verify", get(web::verify))
diff --git a/src/mcp.rs b/src/mcp.rs
new file mode 100644
index 0000000..7aac9c5
--- /dev/null
+++ b/src/mcp.rs
@@ -0,0 +1,226 @@
+use crate::auth::{self, User};
+use crate::repos;
+use crate::AppState;
+use axum::extract::State;
+use axum::http::{HeaderMap, StatusCode};
+use axum::response::{Html, IntoResponse, Response};
+use axum::Json;
+use serde_json::{json, Value};
+
+const PROTOCOL: &str = "2024-11-05";
+
+fn rpc_ok(id: Value, result: Value) -> Json<Value> {
+ Json(json!({"jsonrpc": "2.0", "id": id, "result": result}))
+}
+fn rpc_err(id: Value, code: i64, msg: &str) -> Json<Value> {
+ Json(json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": msg}}))
+}
+fn text(s: impl Into<String>) -> Value {
+ json!({"content": [{"type": "text", "text": s.into()}]})
+}
+fn text_err(s: impl Into<String>) -> Value {
+ json!({"content": [{"type": "text", "text": s.into()}], "isError": true})
+}
+
+/// Tool catalogue.
+fn tool_defs() -> Value {
+ json!([
+ {
+ "name": "okibi_whoami",
+ "description": "認証中のユーザー(handle / email / admin)を返す",
+ "inputSchema": {"type": "object", "properties": {}}
+ },
+ {
+ "name": "okibi_list_repos",
+ "description": "自分に見えるリポジトリ(自分の全部+公開)を一覧する",
+ "inputSchema": {"type": "object", "properties": {}}
+ },
+ {
+ "name": "okibi_create_repo",
+ "description": "リポジトリを新規作成する",
+ "inputSchema": {"type": "object", "properties": {
+ "name": {"type": "string", "description": "リポ名(英数字 . _ -)"},
+ "public": {"type": "boolean", "description": "公開するか(既定 false=private)"},
+ "description": {"type": "string"}
+ }, "required": ["name"]}
+ },
+ {
+ "name": "okibi_list_tree",
+ "description": "リポジトリ内のディレクトリを一覧する",
+ "inputSchema": {"type": "object", "properties": {
+ "owner": {"type": "string"}, "repo": {"type": "string"},
+ "path": {"type": "string", "description": "サブパス(省略=ルート)"},
+ "rev": {"type": "string", "description": "ブランチ/コミット(省略=既定ブランチ)"}
+ }, "required": ["owner", "repo"]}
+ },
+ {
+ "name": "okibi_get_file",
+ "description": "リポジトリ内のテキストファイルの中身を取得する",
+ "inputSchema": {"type": "object", "properties": {
+ "owner": {"type": "string"}, "repo": {"type": "string"},
+ "path": {"type": "string"}, "rev": {"type": "string"}
+ }, "required": ["owner", "repo", "path"]}
+ }
+ ])
+}
+
+// GET /mcp — human/info page.
+pub async fn mcp_info() -> Response {
+ Html(
+ r#"<!doctype html><meta charset=utf-8><title>Okibi MCP</title>
+<body style="font-family:system-ui;background:#0f0d0c;color:#f3ece6;max-width:640px;margin:40px auto;padding:0 16px">
+<h1>🔥 Okibi MCP</h1>
+<p>Okibi の repo を AI から操作する MCP エンドポイント(HTTP / JSON-RPC)。</p>
+<pre style="background:#1a1614;border:1px solid #2a2320;padding:12px;border-radius:8px;white-space:pre-wrap">claude mcp add --transport http okibi https://okibi.fly.dev/mcp \
+ --header "Authorization: Bearer <あなたのPAT>"</pre>
+<p>PAT は <a style="color:#f0a868" href="/settings">設定</a> で発行。tools: okibi_whoami / okibi_list_repos / okibi_create_repo / okibi_list_tree / okibi_get_file</p>
+</body>"#,
+ )
+ .into_response()
+}
+
+// POST /mcp — JSON-RPC 2.0.
+pub async fn mcp_post(
+ State(state): State<AppState>,
+ headers: HeaderMap,
+ body: Json<Value>,
+) -> Response {
+ let req = body.0;
+ let id = req.get("id").cloned().unwrap_or(Value::Null);
+ let method = req.get("method").and_then(|m| m.as_str()).unwrap_or("");
+
+ match method {
+ "initialize" => rpc_ok(
+ id,
+ json!({
+ "protocolVersion": PROTOCOL,
+ "capabilities": {"tools": {}},
+ "serverInfo": {"name": "okibi", "version": env!("CARGO_PKG_VERSION")}
+ }),
+ )
+ .into_response(),
+ m if m.starts_with("notifications/") => StatusCode::ACCEPTED.into_response(),
+ "ping" => rpc_ok(id, json!({})).into_response(),
+ "tools/list" => rpc_ok(id, json!({"tools": tool_defs()})).into_response(),
+ "tools/call" => {
+ let user = match auth::bearer(&headers) {
+ Some(tok) => auth::user_by_pat(&state.db, &tok).await,
+ None => None,
+ };
+ call_tool(&state, id, &req, user).await.into_response()
+ }
+ _ => rpc_err(id, -32601, "method not found").into_response(),
+ }
+}
+
+async fn call_tool(state: &AppState, id: Value, req: &Value, user: Option<User>) -> Json<Value> {
+ let params = req.get("params").cloned().unwrap_or(json!({}));
+ let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
+ let args = params.get("arguments").cloned().unwrap_or(json!({}));
+
+ // All tools require a valid PAT.
+ let user = match user {
+ Some(u) => u,
+ None => return rpc_ok(id, text_err("認証が必要です。Authorization: Bearer <PAT> を付けてください。")),
+ };
+
+ let result = match name {
+ "okibi_whoami" => text(format!(
+ "handle={} email={} admin={}",
+ user.handle, user.email, user.is_admin != 0
+ )),
+ "okibi_list_repos" => {
+ let repos = repos::list_visible(&state.db, Some(&user)).await;
+ let lines: Vec<String> = repos
+ .iter()
+ .map(|(r, owner)| {
+ format!(
+ "{}/{} [{}]{}",
+ owner,
+ r.name,
+ r.visibility,
+ if r.description.is_empty() { String::new() } else { format!(" — {}", r.description) }
+ )
+ })
+ .collect();
+ text(if lines.is_empty() { "(リポジトリなし)".into() } else { lines.join("\n") })
+ }
+ "okibi_create_repo" => {
+ let rname = args.get("name").and_then(|v| v.as_str()).unwrap_or("").trim();
+ if !crate::util::valid_name(rname) {
+ text_err("name が不正です(英数字 . _ - のみ)。")
+ } else if repos::get_repo(&state.db, user.id, rname).await.is_some() {
+ text_err("同名のリポジトリが既にあります。")
+ } else {
+ let vis = if args.get("public").and_then(|v| v.as_bool()).unwrap_or(false) {
+ "public"
+ } else {
+ "private"
+ };
+ let desc = args.get("description").and_then(|v| v.as_str()).unwrap_or("");
+ match repos::create_repo(&state.db, &state.cfg, &user, rname, vis, desc).await {
+ Ok(_) => text(format!(
+ "作成しました: {}/{} [{}]\nclone: {}/{}/{}.git",
+ user.handle, rname, vis, state.cfg.base_url, user.handle, rname
+ )),
+ Err(e) => text_err(format!("作成失敗: {e}")),
+ }
+ }
+ }
+ "okibi_list_tree" => match resolve_readable(state, &args, &user).await {
+ Ok((owner, repo)) => {
+ let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
+ let rev = args
+ .get("rev")
+ .and_then(|v| v.as_str())
+ .unwrap_or(&repo.default_branch);
+ let entries = repos::ls_tree(&state.cfg, &owner, &repo.name, rev, path);
+ let lines: Vec<String> = entries
+ .iter()
+ .map(|(_, ty, n)| format!("{} {}", if ty == "tree" { "📁" } else { "📄" }, n))
+ .collect();
+ text(if lines.is_empty() { "(空)".into() } else { lines.join("\n") })
+ }
+ Err(e) => text_err(e),
+ },
+ "okibi_get_file" => match resolve_readable(state, &args, &user).await {
+ Ok((owner, repo)) => {
+ let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
+ let rev = args
+ .get("rev")
+ .and_then(|v| v.as_str())
+ .unwrap_or(&repo.default_branch);
+ match repos::read_blob(&state.cfg, &owner, &repo.name, rev, path) {
+ Some(bytes) if std::str::from_utf8(&bytes).is_ok() && bytes.len() < 256 * 1024 => {
+ text(String::from_utf8_lossy(&bytes).to_string())
+ }
+ Some(_) => text_err("バイナリまたは大きすぎるファイルです。"),
+ None => text_err("ファイルが見つかりません。"),
+ }
+ }
+ Err(e) => text_err(e),
+ },
+ _ => text_err(format!("unknown tool: {name}")),
+ };
+ rpc_ok(id, result)
+}
+
+/// Resolve (owner_handle, repo) from args and enforce read visibility.
+async fn resolve_readable(
+ state: &AppState,
+ args: &Value,
+ viewer: &User,
+) -> Result<(String, repos::Repo), String> {
+ let owner = args.get("owner").and_then(|v| v.as_str()).unwrap_or("").trim();
+ let repo = args.get("repo").and_then(|v| v.as_str()).unwrap_or("").trim();
+ let owner_user = auth::user_by_handle(&state.db, owner)
+ .await
+ .ok_or_else(|| "owner が見つかりません".to_string())?;
+ let r = repos::get_repo(&state.db, owner_user.id, repo)
+ .await
+ .ok_or_else(|| "repo が見つかりません".to_string())?;
+ if !r.is_public() && owner_user.id != viewer.id {
+ return Err("非公開リポジトリです".to_string());
+ }
+ Ok((owner_user.handle, r))
+}
diff --git a/src/web.rs b/src/web.rs
index 8591aa6..74cbda6 100644
--- a/src/web.rs
+++ b/src/web.rs
@@ -48,7 +48,7 @@ async fn current(state: &AppState, headers: &HeaderMap) -> Option<User> {
}
/// Feature list — single source of truth for the about page & home hero.
-fn features() -> [(&'static str, &'static str, &'static str); 8] {
+fn features() -> [(&'static str, &'static str, &'static str); 9] {
[
("🔥", "焚き火だけ", "atsm の仲間だけがログイン。magic-link と管理者の招待リンクで、メールサーバ無しでも入れる。"),
("📦", "リポジトリ", "公開/非公開を選んで作成。git push / pull は HTTPS + アクセストークンで。"),
@@ -58,6 +58,7 @@ fn features() -> [(&'static str, &'static str, &'static str); 8] {
("🔑", "トークン", "用途ごとに PAT を発行。パスワード代わりに push、いつでも作り直せる。"),
("👥", "メンバー", "管理者が allowlist を管理し、招待リンクを発行。焚き火の輪を広げる。"),
("🛡", "安全より", "秘密はハッシュで保存。git 処理は実 git に委譲し、自前で pack を再実装しない。"),
+ ("🤖", "MCP", "Claude などの AI から /mcp に PAT で接続。repo 一覧・作成・ファイル取得をツールで直接。"),
]
}
@@ -523,6 +524,16 @@ pub async fn settings(State(state): State<AppState>, headers: HeaderMap) -> Resp
}
}
}
+ section.card {
+ h2 { "🤖 MCP(AIから使う)" }
+ p.muted { "Claude Code などから Okibi を直接操作。PAT を発行して下のコマンドの " code { "<PAT>" } " に貼り付け。" }
+ pre { (format!("claude mcp add --transport http okibi {}/mcp \\\n --header \"Authorization: Bearer <PAT>\"", state.cfg.base_url)) }
+ p.muted { "tools: okibi_whoami / okibi_list_repos / okibi_create_repo / okibi_list_tree / okibi_get_file" }
+ }
+ section.card {
+ h2 { "git で使う" }
+ pre { (format!("git clone {}/{}/REPO.git\nUsername: {}\nPassword: <PAT>", state.cfg.base_url, user.handle, user.handle)) }
+ }
};
Html(layout("設定", &Some(user), body).into_string()).into_response()
}