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

210 lines9,498 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};
10use crate::tools::{Gate, TOOLS, Tool};
11
12const SUPPORTED_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"];
13
14const INSTRUCTIONS: &str = "g1t is a git forge where people and agents work through issues and pull requests. Repositories are named \"owner/name\"; issues and pull requests in one share a sequence of numbers.
15Tools are resources, each with an `action`: search, repository, issue, pull_request, agent, plan, memory, workflow, secret, webhook, access, workspace, account. The `action` field lists each action and the fields it needs. You see only what your token's scopes allow; a refusal names the scope it needs.
16Find a repository: account whoami lists your workspaces; repository list or search finds one.
17Work on an issue: issue get (read it and the pull requests already made for it), memory recall, then pull_request create with the issue's number: you get a draft with its own fork to clone and push to. Record your reasoning with pull_request record_session as you go, push, then pull_request ready with a summary. Watch `overlaps` and `behind` on pull_request get, and its checks there: `statuses` from the repository's workflows and `required_checks`, which must pass before it merges. If one fails, read why with workflow get_run and job_logs, push a fix, and the checks run again.
18Hand work to g1t's agent: agent delegate opens an issue and starts it in one step; agent assign starts it on an existing issue. Each costs the workspace money.
19When you learn something the next agent needs, memory remember it (scope project or workspace). Never a secret.";
20
21fn result(id: &Value, value: Value) -> Value {
22 json!({ "jsonrpc": "2.0", "id": id, "result": value })
23}
24
25fn error(id: &Value, code: i32, message: &str) -> Value {
26 json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
27}
28
29/// What decides the actions a caller sees: an agent's run scope, an
30/// access token's scopes, or nothing beyond the person's role.
31fn gate<'a>(services: &'a Services, viewer: &'a Viewer) -> Gate<'a> {
32 if let Some(scope) = &services.scope {
33 return Gate::Agent(scope);
34 }
35 match viewer.as_ref().and_then(|user| user.token.as_deref()) {
36 Some(access) => Gate::Token(access),
37 None => Gate::Everything,
38 }
39}
40
41/// Answers one JSON-RPC request, or `None` for a notification.
42async fn answer(services: &Services, viewer: &Viewer, request: &Value) -> Result<Option<Value>> {
43 // Notifications carry no id and get no response.
44 let Some(id) = request.get("id") else {
45 return Ok(None);
46 };
47 let params = &request["params"];
48 let answer = match request["method"].as_str().unwrap_or_default() {
49 "initialize" => {
50 let requested = params["protocolVersion"].as_str().unwrap_or_default();
51 let version = SUPPORTED_VERSIONS
52 .into_iter()
53 .find(|version| *version == requested)
54 .unwrap_or(SUPPORTED_VERSIONS[0]);
55 result(
56 id,
57 json!({
58 "protocolVersion": version,
59 "capabilities": { "tools": {} },
60 "serverInfo": { "name": "g1t", "version": "0.1.0" },
61 "instructions": INSTRUCTIONS,
62 }),
63 )
64 }
65 "ping" => result(id, json!({})),
66 "tools/list" => {
67 // A caller sees the tools, and the actions of each, that its
68 // token may use.
69 let gate = gate(services, viewer);
70 let tools: Vec<Value> = TOOLS.iter().filter_map(|tool| tool.listed(&gate)).collect();
71 result(id, json!({ "tools": tools }))
72 }
73 "tools/call" => {
74 let name = params["name"].as_str().unwrap_or_default();
75 let arguments = &params["arguments"];
76 let op = match Tool::by_name(name) {
77 Some(tool) => match crate::tools::resolve(tool, arguments) {
78 Ok(op) => op,
79 Err(problem) => {
80 return Ok(Some(result(
81 id,
82 json!({ "content": [{ "type": "text", "text": problem }], "isError": true }),
83 )));
84 }
85 },
86 // A tool per operation, as the server had before its
87 // resource tools. Still answered, no longer listed.
88 None => match Op::by_name(name) {
89 Some(op) => op,
90 None => return Ok(Some(error(id, -32602, "Unknown tool."))),
91 },
92 };
93 let outcome = crate::audit::run(op, services, viewer, arguments).await?;
94 // A failed operation is a tool result the model can read and
95 // act on, not a protocol error.
96 let (text, failed) = match outcome {
97 // In `snake_case`, as the REST API answers; the protocol's
98 // own envelope keeps MCP's spelling.
99 Outcome::Ok(value) => (
100 serde_json::to_string_pretty(&g1t_kit::wire::snake_case(value))?,
101 false,
102 ),
103 Outcome::Fail(failure) => (failure.message, true),
104 };
105 result(
106 id,
107 json!({ "content": [{ "type": "text", "text": text }], "isError": failed }),
108 )
109 }
110 method => error(id, -32601, &format!("Method not found: {method}")),
111 };
112 Ok(Some(answer))
113}
114
115/// What someone sees when they open the server's address in a browser:
116/// what this is, how to connect, and what it offers.
117fn card() -> Value {
118 let tools: Vec<Value> = TOOLS
119 .iter()
120 .map(|tool| {
121 let actions: Vec<&crate::tools::Action> = tool.actions.iter().collect();
122 json!({
123 "name": tool.name,
124 "title": tool.title,
125 "description": tool.description,
126 "actions": tool.actions.iter().map(|action| json!({
127 "name": action.name,
128 "description": action.summary,
129 "operation": action.op.name(),
130 "scope": g1t_contracts::scopes::scope_for(action.op.name()).map(|scope| scope.as_str()),
131 })).collect::<Vec<_>>(),
132 "input_schema": tool.discriminated(&actions),
133 })
134 })
135 .collect();
136 json!({
137 "name": "g1t",
138 "description": "The g1t MCP server: issues, pull requests and sessions for agents.",
139 "endpoint": "https://mcp.g1t.sh",
140 "transport": "streamable-http",
141 "protocol_versions": SUPPORTED_VERSIONS,
142 "connect": "claude mcp add --transport http g1t https://mcp.g1t.sh",
143 "authorization": {
144 "required": true,
145 "oauth_protected_resource": "https://mcp.g1t.sh/.well-known/oauth-protected-resource",
146 "alternative": "Authorization: Bearer <g1t access token>",
147 },
148 "documentation_url": "https://docs.g1t.sh/guides/bring-your-own-agent/",
149 "instructions": INSTRUCTIONS,
150 "tools": tools,
151 })
152}
153
154pub async fn handle(
155 mut request: Request,
156 services: &Services,
157 viewer: &Viewer,
158) -> Result<Response> {
159 if request.method() != Method::Post {
160 // A client asking for a stream of server messages is told there is
161 // none. Anyone else, a person with a browser, gets a description.
162 let wants_stream = request
163 .headers()
164 .get("accept")?
165 .is_some_and(|accept| accept.contains("text/event-stream"));
166 if request.method() == Method::Get && !wants_stream {
167 return Response::from_json(&card());
168 }
169 let mut response = Response::empty()?.with_status(405);
170 response.headers_mut().set("allow", "GET, POST")?;
171 return Ok(response);
172 }
173 // Calls need a signed-in user. Answering 401 with this header is what
174 // makes a client open the browser to sign in.
175 if viewer.is_none() {
176 let mut response = Response::from_json(&error(
177 &Value::Null,
178 -32001,
179 "Sign in to use the g1t MCP server.",
180 ))?
181 .with_status(401);
182 response
183 .headers_mut()
184 .set("www-authenticate", MCP_CHALLENGE)?;
185 return Ok(response);
186 }
187 let Ok(body) = request.json::<Value>().await else {
188 return Ok(
189 Response::from_json(&error(&Value::Null, -32700, "Parse error"))?.with_status(400),
190 );
191 };
192 let accepted = || Ok(Response::empty()?.with_status(202));
193 match body {
194 Value::Array(batch) => {
195 let mut answers = Vec::new();
196 for request in &batch {
197 answers.extend(answer(services, viewer, request).await?);
198 }
199 if answers.is_empty() {
200 accepted()
201 } else {
202 Response::from_json(&answers)
203 }
204 }
205 single => match answer(services, viewer, &single).await? {
206 Some(answer) => Response::from_json(&answer),
207 None => accepted(),
208 },
209 }
210}