yuki / okibi
焚き火の仲間だけの自前Gitホスティング(このサイト自身)
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))
}