🔥 Okibi

yuki / okibi

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

git clone https://okibi.fly.dev/yuki/okibi.git

okibi / src / mcp.rs

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))
}