Skip to content
227 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, memory, 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 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. 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.
17Record 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.
18Merge 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.
19Memory: memory recall before you start to learn how the project builds and what it has decided; when you learn something the next agent needs, memory remember it, one short fact at a time (scope project or workspace). Never a secret.
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 "connect": format!("claude mcp add --transport http g1t {}", addresses.mcp),
146 "authorization": {
147 "required": true,
148 "oauth_protected_resource": addresses.protected_resource(),
149 "alternative": "Authorization: Bearer <g1t access token>",
150 },
151 "documentation_url": "https://docs.g1t.sh/guides/bring-your-own-agent/",
152 "instructions": INSTRUCTIONS,
153 "tools": tools,
154 })
155}
156
157pub async fn handle(
158 mut request: Request,
159 services: &Services,
160 viewer: &Viewer,
161) -> Result<Response> {
162 if request.method() != Method::Post {
163 // A client asking for a stream of server messages is told there is
164 // none. Anyone else, a person with a browser, gets a description.
165 let wants_stream = request
166 .headers()
167 .get("accept")?
168 .is_some_and(|accept| accept.contains("text/event-stream"));
169 if request.method() == Method::Get && !wants_stream {
170 return Response::from_json(&card(&services.addresses));
171 }
172 let mut response = Response::empty()?.with_status(405);
173 response.headers_mut().set("allow", "GET, POST")?;
174 return Ok(response);
175 }
176 // Calls need a signed-in user. Answering 401 with this header is what
177 // makes a client open the browser to sign in.
178 if viewer.is_none() {
179 let mut response = Response::from_json(&error(
180 &Value::Null,
181 -32001,
182 "Sign in to use the g1t MCP server.",
183 ))?
184 .with_status(401);
185 response
186 .headers_mut()
187 .set("www-authenticate", &services.addresses.mcp_challenge())?;
188 return Ok(response);
189 }
190 let Ok(body) = request.json::<Value>().await else {
191 return Ok(
192 Response::from_json(&error(&Value::Null, -32700, "Parse error"))?.with_status(400),
193 );
194 };
195 let accepted = || Ok(Response::empty()?.with_status(202));
196 match body {
197 Value::Array(batch) => {
198 let mut answers = Vec::new();
199 for request in &batch {
200 answers.extend(answer(services, viewer, request).await?);
201 }
202 if answers.is_empty() {
203 accepted()
204 } else {
205 Response::from_json(&answers)
206 }
207 }
208 single => match answer(services, viewer, &single).await? {
209 Some(answer) => Response::from_json(&answer),
210 None => accepted(),
211 },
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 /// The instructions name every tool, in the order `tools/list` gives
220 /// them, so that no tool goes unmentioned.
221 #[test]
222 fn the_instructions_list_every_tool() {
223 let names: Vec<&str> = TOOLS.iter().map(|tool| tool.name).collect();
224 let listed = format!("each with an `action`: {}.", names.join(", "));
225 assert!(INSTRUCTIONS.contains(&listed), "{listed}");
226 }
227}