Skip to content

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.sh
273 linesCodeBlameRaw
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::operations::{Op, Services};
9use crate::tools::{Gate, TOOLS, Tool};
10
11const SUPPORTED_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"];
12
13const INSTRUCTIONS: &str = "g1t is where your code, its history and the work around it live: repositories, issues, pull requests, workflows, deployments and packages. You bring your own agent; these tools are how it reads and changes what is on g1t, as the person or token it acts for. Repositories are named \"owner/name\"; issues and pull requests in one share a sequence of numbers.
14Tools are resources, each with an `action`: search, repository, issue, pull_request, session, workflow, observability, package, secret, webhook, access, team, workspace, ai, billing, security, notifications, account, artifact. 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.
15Find a repository: account whoami lists your workspaces; repository list or search finds one.
16Work on an issue: issue work (everything about it: what done means, who is on it, its pull requests, what it waits on), issue claim (advisory: it tells you who else is on it), then pull_request create with the issue's number: you get a draft with its own fork to clone and push to. Push your commits, 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. Merging closes the pull request's issue; a commit pushed straight to the default branch closes one only when its message says `Closes #N` (or Fixes, Resolves), never with a bare `(#N)`, and issue close with commit closes one after the fact.
17Coordinate: session start once per run of your harness (harness_session_id makes it idempotent), session update as a heartbeat, session get between work steps to read pending updates. Reading does not acknowledge: consider relevant changes, record a public decision summary, then session record with acknowledge containing only the exact update IDs considered. Keep your own session ID explicit, especially when sharing a checkout. Separate scopes may proceed concurrently; agree ownership before overlapping edits. session end when done; issue release or handoff (with a note) when you stop.
18Record public summaries only, never private chain-of-thought, credentials or secrets. As you work, pull_request record_session appends user-facing decisions, evidence and tool activity to the pull request's session, so reviewers see why the change is what it is; pull_request read_session reads it back. pull_request record_decision keeps a decision (why the change is made this way, or what fixed a failure) and list_decisions reads them; repository why says why a line of code is the way it is.
19Merge queue: when your pull request fails combined with the ones ahead of it, g1t says which of them it breaks with and the check that failed (on the pull request, its issue and its session, and as integration.failed). Fix it and push: once its checks pass, it rejoins the queue by itself.
20Security: security code_alerts, vulnerability_alerts and secret_alerts show what to fix; fix it in your pull request, which the Code scanning and Dependency review checks judge.
21Production: observability overview and errors show what a project's running software did; follow a request or trace to its logs.";
22
23fn result(id: &Value, value: Value) -> Value {
24 json!({ "jsonrpc": "2.0", "id": id, "result": value })
25}
26
27fn error(id: &Value, code: i32, message: &str) -> Value {
28 json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
29}
30
31/// What decides the actions a caller sees: a run's scope, an
32/// access token's scopes, or nothing beyond the person's role.
33fn gate<'a>(services: &'a Services, viewer: &'a Viewer) -> Gate<'a> {
34 if let Some(scope) = &services.scope {
35 return Gate::Agent(scope);
36 }
37 match viewer.as_ref().and_then(|user| user.token.as_deref()) {
38 Some(access) => Gate::Token(access),
39 None => Gate::Everything,
40 }
41}
42
43/// Answers one JSON-RPC request, or `None` for a notification.
44async fn answer(services: &Services, viewer: &Viewer, request: &Value) -> Result<Option<Value>> {
45 // Notifications carry no id and get no response.
46 let Some(id) = request.get("id") else {
47 return Ok(None);
48 };
49 let params = &request["params"];
50 let answer = match request["method"].as_str().unwrap_or_default() {
51 "initialize" => {
52 let requested = params["protocolVersion"].as_str().unwrap_or_default();
53 let version = SUPPORTED_VERSIONS
54 .into_iter()
55 .find(|version| *version == requested)
56 .unwrap_or(SUPPORTED_VERSIONS[0]);
57 result(
58 id,
59 json!({
60 "protocolVersion": version,
61 "capabilities": { "tools": {} },
62 "serverInfo": { "name": "g1t", "version": "0.1.0" },
63 "instructions": INSTRUCTIONS,
64 }),
65 )
66 }
67 "ping" => result(id, json!({})),
68 "tools/list" => {
69 // A caller sees the tools, and the actions of each, that its
70 // token may use.
71 let gate = gate(services, viewer);
72 let tools: Vec<Value> = TOOLS.iter().filter_map(|tool| tool.listed(&gate)).collect();
73 result(id, json!({ "tools": tools }))
74 }
75 "tools/call" => {
76 let name = params["name"].as_str().unwrap_or_default();
77 let arguments = &params["arguments"];
78 let op = match Tool::by_name(name) {
79 Some(tool) => match crate::tools::resolve(tool, arguments) {
80 Ok(op) => op,
81 Err(problem) => {
82 return Ok(Some(result(
83 id,
84 json!({ "content": [{ "type": "text", "text": problem }], "isError": true }),
85 )));
86 }
87 },
88 // A tool per operation, as the server had before its
89 // resource tools. Still answered, no longer listed; never
90 // one offered over REST alone.
91 None => match Op::by_name(name).filter(|op| !crate::tools::REST_ONLY.contains(op)) {
92 Some(op) => op,
93 None => return Ok(Some(error(id, -32602, "Unknown tool."))),
94 },
95 };
96 let arguments = &crate::tools::with_defaults(op, arguments);
97 let outcome = crate::audit::run(op, services, viewer, arguments).await?;
98 // A failed operation is a tool result the model can read and
99 // act on, not a protocol error.
100 let (text, failed) = match outcome {
101 // In `snake_case`, as the REST API answers; the protocol's
102 // own envelope keeps MCP's spelling.
103 Outcome::Ok(value) => (
104 serde_json::to_string_pretty(&g1t_kit::wire::snake_case_keeping(value, op.as_given()))?,
105 false,
106 ),
107 Outcome::Fail(failure) => (failure.message, true),
108 };
109 result(
110 id,
111 json!({ "content": [{ "type": "text", "text": text }], "isError": failed }),
112 )
113 }
114 method => error(id, -32601, &format!("Method not found: {method}")),
115 };
116 Ok(Some(answer))
117}
118
119/// What someone sees when they open the server's address in a browser:
120/// what this is, how to connect, and what it offers.
121fn card(addresses: &crate::addresses::Addresses) -> Value {
122 let tools: Vec<Value> = TOOLS
123 .iter()
124 .map(|tool| {
125 let actions: Vec<&crate::tools::Action> = tool.actions.iter().collect();
126 json!({
127 "name": tool.name,
128 "title": tool.title,
129 "description": tool.description,
130 "actions": tool.actions.iter().map(|action| json!({
131 "name": action.name,
132 "description": action.summary,
133 "operation": action.op.name(),
134 "scope": g1t_contracts::scopes::scope_for(action.op.name()).map(|scope| scope.as_str()),
135 })).collect::<Vec<_>>(),
136 "input_schema": tool.discriminated(&actions),
137 })
138 })
139 .collect();
140 let mut card = json!({
141 "name": "g1t",
142 "description": "The g1t MCP server: issues, pull requests and sessions for agents.",
143 "endpoint": addresses.mcp,
144 "transport": "streamable-http",
145 "protocol_versions": SUPPORTED_VERSIONS,
146 // For Claude Code, g1t's plugin comes first: it adds this server
147 // itself (as plugin:g1t:g1t), so `connect` is for Claude Code
148 // without the plugin; with both, every tool is listed twice.
149 "connect": format!("claude mcp add --transport http g1t {}", addresses.mcp),
150 "claude_code_plugin": {
151 "install": [
152 "claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git",
153 "claude plugin install g1t@g1t",
154 ],
155 "note": "The plugin adds this server (listed in /mcp as plugin:g1t:g1t) and records your sessions on g1t. With it installed, skip `connect`, or each tool is listed twice.",
156 "documentation_url": "https://docs.g1t.sh/guides/claude-code-plugin/",
157 },
158 "authorization": {
159 "required": true,
160 "oauth_protected_resource": addresses.protected_resource(),
161 "alternative": "Authorization: Bearer <g1t access token>",
162 },
163 "documentation_url": "https://docs.g1t.sh/guides/bring-your-own-agent/",
164 "instructions": INSTRUCTIONS,
165 "tools": tools,
166 });
167 // Codex also reads the endpoint itself as protected resource metadata.
168 // Keep the public card useful to people and valid for OAuth discovery.
169 if let (Some(card), Value::Object(metadata)) = (
170 card.as_object_mut(),
171 crate::oauth::protected_resource_metadata(addresses),
172 ) {
173 card.extend(metadata);
174 }
175 card
176}
177
178pub async fn handle(
179 mut request: Request,
180 services: &Services,
181 viewer: &Viewer,
182) -> Result<Response> {
183 if request.method() != Method::Post {
184 // A client asking for a stream of server messages is told there is
185 // none. Anyone else, a person with a browser, gets a description.
186 let wants_stream = request
187 .headers()
188 .get("accept")?
189 .is_some_and(|accept| accept.contains("text/event-stream"));
190 if request.method() == Method::Get && !wants_stream {
191 return Response::from_json(&card(&services.addresses));
192 }
193 let mut response = Response::empty()?.with_status(405);
194 response.headers_mut().set("allow", "GET, POST")?;
195 return Ok(response);
196 }
197 // Calls need a signed-in user. Answering 401 with this header is what
198 // makes a client open the browser to sign in.
199 if viewer.is_none() {
200 let mut response = Response::from_json(&error(
201 &Value::Null,
202 -32001,
203 "Sign in to use the g1t MCP server.",
204 ))?
205 .with_status(401);
206 response
207 .headers_mut()
208 .set("www-authenticate", &services.addresses.mcp_challenge())?;
209 return Ok(response);
210 }
211 let Ok(body) = request.json::<Value>().await else {
212 return Ok(
213 Response::from_json(&error(&Value::Null, -32700, "Parse error"))?.with_status(400),
214 );
215 };
216 let accepted = || Ok(Response::empty()?.with_status(202));
217 match body {
218 Value::Array(batch) => {
219 let mut answers = Vec::new();
220 for request in &batch {
221 answers.extend(answer(services, viewer, request).await?);
222 }
223 if answers.is_empty() {
224 accepted()
225 } else {
226 Response::from_json(&answers)
227 }
228 }
229 single => match answer(services, viewer, &single).await? {
230 Some(answer) => Response::from_json(&answer),
231 None => accepted(),
232 },
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn server_card_supports_oauth_discovery() {
242 use crate::addresses::Addresses;
243
244 for addresses in [
245 Addresses::default(),
246 Addresses::from_settings(
247 Some("https://forge.example".into()),
248 Some("https://api.forge.example".into()),
249 Some("https://api.forge.example/mcp".into()),
250 ),
251 ] {
252 let card = card(&addresses);
253 assert_eq!(card["resource"], addresses.mcp);
254 assert_eq!(card["authorization_servers"], json!([addresses.api]));
255 assert_eq!(card["bearer_methods_supported"], json!(["header"]));
256 assert!(card["scopes_supported"].as_array().is_some_and(|scopes| !scopes.is_empty()));
257 for (key, value) in crate::oauth::protected_resource_metadata(&addresses).as_object().unwrap() {
258 assert_eq!(&card[key], value, "discovery field {key}");
259 }
260 assert_eq!(card["endpoint"], addresses.mcp);
261 assert!(card["tools"].as_array().is_some_and(|tools| !tools.is_empty()));
262 }
263 }
264
265 /// The instructions name every tool, in the order `tools/list` gives
266 /// them, so that no tool goes unmentioned.
267 #[test]
268 fn the_instructions_list_every_tool() {
269 let names: Vec<&str> = TOOLS.iter().map(|tool| tool.name).collect();
270 let listed = format!("each with an `action`: {}.", names.join(", "));
271 assert!(INSTRUCTIONS.contains(&listed), "{listed}");
272 }
273}