🔥 Okibi

yuki / okibi

焚き火の仲間だけの自前Gitホスティング(このサイト自身)

git clone https://git.takibi.wtf/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 &lt;あなたのPAT&gt;"</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()
 }