g1t/apps/api/src/mcp.rs

165 lines6,684 bytesCodeBlame
1//! MCP over streamable HTTP. The server keeps no session state, so every
2//! POST is answered directly with JSON.
3
4use g1t_contracts::{Outcome, Viewer};
5use serde_json::{Value, json};
6use worker::{Method, Request, Response, Result};
7
8use crate::oauth::MCP_CHALLENGE;
9use crate::operations::{Op, Services};
10
11const SUPPORTED_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"];
12
13const INSTRUCTIONS: &str = "g1t is a git forge with issues and pull requests, built so that many agents can work on the same issue at once.
14To work on an issue: get_issue to read it and see the pull requests already made for it, then create_pull_request with the issue's number. You get a draft pull request with its own fork to clone and push to. Call record_session as you work so people can see your reasoning, push your commits, and call mark_pull_request_ready with a summary.
15Issues and pull requests are named by repository (\"owner/name\") and number, and share one sequence of numbers.";
16
17fn result(id: &Value, value: Value) -> Value {
18 json!({ "jsonrpc": "2.0", "id": id, "result": value })
19}
20
21fn error(id: &Value, code: i32, message: &str) -> Value {
22 json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
23}
24
25/// Answers one JSON-RPC request, or `None` for a notification.
26async fn answer(services: &Services, viewer: &Viewer, request: &Value) -> Result<Option<Value>> {
27 // Notifications carry no id and get no response.
28 let Some(id) = request.get("id") else {
29 return Ok(None);
30 };
31 let params = &request["params"];
32 let answer = match request["method"].as_str().unwrap_or_default() {
33 "initialize" => {
34 let requested = params["protocolVersion"].as_str().unwrap_or_default();
35 let version = SUPPORTED_VERSIONS
36 .into_iter()
37 .find(|version| *version == requested)
38 .unwrap_or(SUPPORTED_VERSIONS[0]);
39 result(
40 id,
41 json!({
42 "protocolVersion": version,
43 "capabilities": { "tools": {} },
44 "serverInfo": { "name": "g1t", "version": "0.1.0" },
45 "instructions": INSTRUCTIONS,
46 }),
47 )
48 }
49 "ping" => result(id, json!({})),
50 "tools/list" => {
51 let tools: Vec<Value> = Op::ALL
52 .into_iter()
53 .map(|op| {
54 json!({
55 "name": op.name(),
56 "description": op.description(),
57 "inputSchema": op.input(),
58 })
59 })
60 .collect();
61 result(id, json!({ "tools": tools }))
62 }
63 "tools/call" => {
64 let Some(op) = Op::by_name(params["name"].as_str().unwrap_or_default()) else {
65 return Ok(Some(error(id, -32602, "Unknown tool.")));
66 };
67 let outcome = op.run(services, viewer, &params["arguments"]).await?;
68 // A failed operation is a tool result the model can read and
69 // act on, not a protocol error.
70 let (text, failed) = match outcome {
71 Outcome::Ok(value) => (serde_json::to_string_pretty(&value)?, false),
72 Outcome::Fail(failure) => (failure.message, true),
73 };
74 result(
75 id,
76 json!({ "content": [{ "type": "text", "text": text }], "isError": failed }),
77 )
78 }
79 method => error(id, -32601, &format!("Method not found: {method}")),
80 };
81 Ok(Some(answer))
82}
83
84/// What someone sees when they open the server's address in a browser:
85/// what this is, how to connect, and what it offers.
86fn card() -> Value {
87 let tools: Vec<Value> = Op::ALL
88 .into_iter()
89 .map(|op| json!({ "name": op.name(), "description": op.description() }))
90 .collect();
91 json!({
92 "name": "g1t",
93 "description": "The g1t MCP server: issues, pull requests and sessions for agents.",
94 "endpoint": "https://mcp.g1t.sh",
95 "transport": "streamable-http",
96 "protocol_versions": SUPPORTED_VERSIONS,
97 "connect": "claude mcp add --transport http g1t https://mcp.g1t.sh",
98 "authorization": {
99 "required": true,
100 "oauth_protected_resource": "https://mcp.g1t.sh/.well-known/oauth-protected-resource",
101 "alternative": "Authorization: Bearer <g1t access token>",
102 },
103 "documentation_url": "https://docs.g1t.sh/guides/bring-your-own-agent/",
104 "instructions": INSTRUCTIONS,
105 "tools": tools,
106 })
107}
108
109pub async fn handle(
110 mut request: Request,
111 services: &Services,
112 viewer: &Viewer,
113) -> Result<Response> {
114 if request.method() != Method::Post {
115 // A client asking for a stream of server messages is told there is
116 // none. Anyone else, a person with a browser, gets a description.
117 let wants_stream = request
118 .headers()
119 .get("accept")?
120 .is_some_and(|accept| accept.contains("text/event-stream"));
121 if request.method() == Method::Get && !wants_stream {
122 return Response::from_json(&card());
123 }
124 let mut response = Response::empty()?.with_status(405);
125 response.headers_mut().set("allow", "GET, POST")?;
126 return Ok(response);
127 }
128 // Calls need a signed-in user. Answering 401 with this header is what
129 // makes a client open the browser to sign in.
130 if viewer.is_none() {
131 let mut response = Response::from_json(&error(
132 &Value::Null,
133 -32001,
134 "Sign in to use the g1t MCP server.",
135 ))?
136 .with_status(401);
137 response
138 .headers_mut()
139 .set("www-authenticate", MCP_CHALLENGE)?;
140 return Ok(response);
141 }
142 let Ok(body) = request.json::<Value>().await else {
143 return Ok(
144 Response::from_json(&error(&Value::Null, -32700, "Parse error"))?.with_status(400),
145 );
146 };
147 let accepted = || Ok(Response::empty()?.with_status(202));
148 match body {
149 Value::Array(batch) => {
150 let mut answers = Vec::new();
151 for request in &batch {
152 answers.extend(answer(services, viewer, request).await?);
153 }
154 if answers.is_empty() {
155 accepted()
156 } else {
157 Response::from_json(&answers)
158 }
159 }
160 single => match answer(services, viewer, &single).await? {
161 Some(answer) => Response::from_json(&answer),
162 None => accepted(),
163 },
164 }
165}