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
238 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, 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.
17Coordinate: session start once per run of your harness (harness_session_id makes it idempotent), session update as a heartbeat, session record your prompts and tool calls, session end when done; issue release or handoff (with a note) when you stop.
18Record your session: as you work, pull_request record_session appends your prompts, reasoning and the tools you ran 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 outcome = crate::audit::run(op, services, viewer, arguments).await?;
97 // A failed operation is a tool result the model can read and
98 // act on, not a protocol error.
99 let (text, failed) = match outcome {
100 // In `snake_case`, as the REST API answers; the protocol's
101 // own envelope keeps MCP's spelling.
102 Outcome::Ok(value) => (
103 serde_json::to_string_pretty(&g1t_kit::wire::snake_case_keeping(value, op.as_given()))?,
104 false,
105 ),
106 Outcome::Fail(failure) => (failure.message, true),
107 };
108 result(
109 id,
110 json!({ "content": [{ "type": "text", "text": text }], "isError": failed }),
111 )
112 }
113 method => error(id, -32601, &format!("Method not found: {method}")),
114 };
115 Ok(Some(answer))
116}
117
118/// What someone sees when they open the server's address in a browser:
119/// what this is, how to connect, and what it offers.
120fn card(addresses: &crate::addresses::Addresses) -> Value {
121 let tools: Vec<Value> = TOOLS
122 .iter()
123 .map(|tool| {
124 let actions: Vec<&crate::tools::Action> = tool.actions.iter().collect();
125 json!({
126 "name": tool.name,
127 "title": tool.title,
128 "description": tool.description,
129 "actions": tool.actions.iter().map(|action| json!({
130 "name": action.name,
131 "description": action.summary,
132 "operation": action.op.name(),
133 "scope": g1t_contracts::scopes::scope_for(action.op.name()).map(|scope| scope.as_str()),
134 })).collect::<Vec<_>>(),
135 "input_schema": tool.discriminated(&actions),
136 })
137 })
138 .collect();
139 json!({
140 "name": "g1t",
141 "description": "The g1t MCP server: issues, pull requests and sessions for agents.",
142 "endpoint": addresses.mcp,
143 "transport": "streamable-http",
144 "protocol_versions": SUPPORTED_VERSIONS,
145 // For Claude Code, g1t's plugin comes first: it adds this server
146 // itself (as plugin:g1t:g1t), so `connect` is for Claude Code
147 // without the plugin; with both, every tool is listed twice.
148 "connect": format!("claude mcp add --transport http g1t {}", addresses.mcp),
149 "claude_code_plugin": {
150 "install": [
151 "claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git",
152 "claude plugin install g1t@g1t",
153 ],
154 "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.",
155 "documentation_url": "https://docs.g1t.sh/guides/claude-code-plugin/",
156 },
157 "authorization": {
158 "required": true,
159 "oauth_protected_resource": addresses.protected_resource(),
160 "alternative": "Authorization: Bearer <g1t access token>",
161 },
162 "documentation_url": "https://docs.g1t.sh/guides/bring-your-own-agent/",
163 "instructions": INSTRUCTIONS,
164 "tools": tools,
165 })
166}
167
168pub async fn handle(
169 mut request: Request,
170 services: &Services,
171 viewer: &Viewer,
172) -> Result<Response> {
173 if request.method() != Method::Post {
174 // A client asking for a stream of server messages is told there is
175 // none. Anyone else, a person with a browser, gets a description.
176 let wants_stream = request
177 .headers()
178 .get("accept")?
179 .is_some_and(|accept| accept.contains("text/event-stream"));
180 if request.method() == Method::Get && !wants_stream {
181 return Response::from_json(&card(&services.addresses));
182 }
183 let mut response = Response::empty()?.with_status(405);
184 response.headers_mut().set("allow", "GET, POST")?;
185 return Ok(response);
186 }
187 // Calls need a signed-in user. Answering 401 with this header is what
188 // makes a client open the browser to sign in.
189 if viewer.is_none() {
190 let mut response = Response::from_json(&error(
191 &Value::Null,
192 -32001,
193 "Sign in to use the g1t MCP server.",
194 ))?
195 .with_status(401);
196 response
197 .headers_mut()
198 .set("www-authenticate", &services.addresses.mcp_challenge())?;
199 return Ok(response);
200 }
201 let Ok(body) = request.json::<Value>().await else {
202 return Ok(
203 Response::from_json(&error(&Value::Null, -32700, "Parse error"))?.with_status(400),
204 );
205 };
206 let accepted = || Ok(Response::empty()?.with_status(202));
207 match body {
208 Value::Array(batch) => {
209 let mut answers = Vec::new();
210 for request in &batch {
211 answers.extend(answer(services, viewer, request).await?);
212 }
213 if answers.is_empty() {
214 accepted()
215 } else {
216 Response::from_json(&answers)
217 }
218 }
219 single => match answer(services, viewer, &single).await? {
220 Some(answer) => Response::from_json(&answer),
221 None => accepted(),
222 },
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 /// The instructions name every tool, in the order `tools/list` gives
231 /// them, so that no tool goes unmentioned.
232 #[test]
233 fn the_instructions_list_every_tool() {
234 let names: Vec<&str> = TOOLS.iter().map(|tool| tool.name).collect();
235 let listed = format!("each with an `action`: {}.", names.join(", "));
236 assert!(INSTRUCTIONS.contains(&listed), "{listed}");
237 }
238}