flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/api/src/mcp.rs

169 lines7,501 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

API and MCP server in Rust; a public index at the API root1//! 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.
Agents as a team: lifecycle, merge queue, billing and a new shell15Before going far, read `overlaps` on get_pull_request: other pull requests in progress that change the same files. One for a different issue will conflict with yours, so narrow your change or say so. `behind` means main has moved; pull it into your fork and push. Once your pull request is ready, the issue's acceptance checks are run for you in a clean sandbox; read their output from get_pull_request and push a fix if they fail.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains16Before you start, recall what the project and its workspace remember; when you learn something the next agent would need, remember it (scope project for this codebase, workspace for what holds across projects). Never a secret.
API and MCP server in Rust; a public index at the API root17Issues and pull requests are named by repository (\"owner/name\") and number, and share one sequence of numbers.";
18
19fn result(id: &Value, value: Value) -> Value {
20 json!({ "jsonrpc": "2.0", "id": id, "result": value })
21}
22
23fn error(id: &Value, code: i32, message: &str) -> Value {
24 json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
25}
26
27/// Answers one JSON-RPC request, or `None` for a notification.
28async fn answer(services: &Services, viewer: &Viewer, request: &Value) -> Result<Option<Value>> {
29 // Notifications carry no id and get no response.
30 let Some(id) = request.get("id") else {
31 return Ok(None);
32 };
33 let params = &request["params"];
34 let answer = match request["method"].as_str().unwrap_or_default() {
35 "initialize" => {
36 let requested = params["protocolVersion"].as_str().unwrap_or_default();
37 let version = SUPPORTED_VERSIONS
38 .into_iter()
39 .find(|version| *version == requested)
40 .unwrap_or(SUPPORTED_VERSIONS[0]);
41 result(
42 id,
43 json!({
44 "protocolVersion": version,
45 "capabilities": { "tools": {} },
46 "serverInfo": { "name": "g1t", "version": "0.1.0" },
47 "instructions": INSTRUCTIONS,
48 }),
49 )
50 }
51 "ping" => result(id, json!({})),
52 "tools/list" => {
Agents as a team: lifecycle, merge queue, billing and a new shell53 // An agent sees only the tools its token may use.
API and MCP server in Rust; a public index at the API root54 let tools: Vec<Value> = Op::ALL
55 .into_iter()
Agents as a team: lifecycle, merge queue, billing and a new shell56 .filter(|op| services.scope.as_ref().is_none_or(|scope| op.allowed_by(scope)))
API and MCP server in Rust; a public index at the API root57 .map(|op| {
58 json!({
59 "name": op.name(),
60 "description": op.description(),
61 "inputSchema": op.input(),
62 })
63 })
64 .collect();
65 result(id, json!({ "tools": tools }))
66 }
67 "tools/call" => {
68 let Some(op) = Op::by_name(params["name"].as_str().unwrap_or_default()) else {
69 return Ok(Some(error(id, -32602, "Unknown tool.")));
70 };
71 let outcome = op.run(services, viewer, &params["arguments"]).await?;
72 // A failed operation is a tool result the model can read and
73 // act on, not a protocol error.
74 let (text, failed) = match outcome {
75 Outcome::Ok(value) => (serde_json::to_string_pretty(&value)?, false),
76 Outcome::Fail(failure) => (failure.message, true),
77 };
78 result(
79 id,
80 json!({ "content": [{ "type": "text", "text": text }], "isError": failed }),
81 )
82 }
83 method => error(id, -32601, &format!("Method not found: {method}")),
84 };
85 Ok(Some(answer))
86}
87
88/// What someone sees when they open the server's address in a browser:
89/// what this is, how to connect, and what it offers.
90fn card() -> Value {
91 let tools: Vec<Value> = Op::ALL
92 .into_iter()
93 .map(|op| json!({ "name": op.name(), "description": op.description() }))
94 .collect();
95 json!({
96 "name": "g1t",
97 "description": "The g1t MCP server: issues, pull requests and sessions for agents.",
98 "endpoint": "https://mcp.g1t.sh",
99 "transport": "streamable-http",
100 "protocol_versions": SUPPORTED_VERSIONS,
101 "connect": "claude mcp add --transport http g1t https://mcp.g1t.sh",
102 "authorization": {
103 "required": true,
104 "oauth_protected_resource": "https://mcp.g1t.sh/.well-known/oauth-protected-resource",
105 "alternative": "Authorization: Bearer <g1t access token>",
106 },
107 "documentation_url": "https://docs.g1t.sh/guides/bring-your-own-agent/",
108 "instructions": INSTRUCTIONS,
109 "tools": tools,
110 })
111}
112
113pub async fn handle(
114 mut request: Request,
115 services: &Services,
116 viewer: &Viewer,
117) -> Result<Response> {
118 if request.method() != Method::Post {
119 // A client asking for a stream of server messages is told there is
120 // none. Anyone else, a person with a browser, gets a description.
121 let wants_stream = request
122 .headers()
123 .get("accept")?
124 .is_some_and(|accept| accept.contains("text/event-stream"));
125 if request.method() == Method::Get && !wants_stream {
126 return Response::from_json(&card());
127 }
128 let mut response = Response::empty()?.with_status(405);
129 response.headers_mut().set("allow", "GET, POST")?;
130 return Ok(response);
131 }
132 // Calls need a signed-in user. Answering 401 with this header is what
133 // makes a client open the browser to sign in.
134 if viewer.is_none() {
135 let mut response = Response::from_json(&error(
136 &Value::Null,
137 -32001,
138 "Sign in to use the g1t MCP server.",
139 ))?
140 .with_status(401);
141 response
142 .headers_mut()
143 .set("www-authenticate", MCP_CHALLENGE)?;
144 return Ok(response);
145 }
146 let Ok(body) = request.json::<Value>().await else {
147 return Ok(
148 Response::from_json(&error(&Value::Null, -32700, "Parse error"))?.with_status(400),
149 );
150 };
151 let accepted = || Ok(Response::empty()?.with_status(202));
152 match body {
153 Value::Array(batch) => {
154 let mut answers = Vec::new();
155 for request in &batch {
156 answers.extend(answer(services, viewer, request).await?);
157 }
158 if answers.is_empty() {
159 accepted()
160 } else {
161 Response::from_json(&answers)
162 }
163 }
164 single => match answer(services, viewer, &single).await? {
165 Some(answer) => Response::from_json(&answer),
166 None => accepted(),
167 },
168 }
169}