Skip to content
226 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.
18Memory: 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.
19Security: 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.
20Production: observability overview and errors show what a project's running software did; follow a request or trace to its logs.";
21
22fn result(id: &Value, value: Value) -> Value {
23 json!({ "jsonrpc": "2.0", "id": id, "result": value })
24}
25
26fn error(id: &Value, code: i32, message: &str) -> Value {
27 json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
28}
29
30/// What decides the actions a caller sees: a run's scope, an
31/// access token's scopes, or nothing beyond the person's role.
32fn gate<'a>(services: &'a Services, viewer: &'a Viewer) -> Gate<'a> {
33 if let Some(scope) = &services.scope {
34 return Gate::Agent(scope);
35 }
36 match viewer.as_ref().and_then(|user| user.token.as_deref()) {
37 Some(access) => Gate::Token(access),
38 None => Gate::Everything,
39 }
40}
41
42/// Answers one JSON-RPC request, or `None` for a notification.
43async fn answer(services: &Services, viewer: &Viewer, request: &Value) -> Result<Option<Value>> {
44 // Notifications carry no id and get no response.
45 let Some(id) = request.get("id") else {
46 return Ok(None);
47 };
48 let params = &request["params"];
49 let answer = match request["method"].as_str().unwrap_or_default() {
50 "initialize" => {
51 let requested = params["protocolVersion"].as_str().unwrap_or_default();
52 let version = SUPPORTED_VERSIONS
53 .into_iter()
54 .find(|version| *version == requested)
55 .unwrap_or(SUPPORTED_VERSIONS[0]);
56 result(
57 id,
58 json!({
59 "protocolVersion": version,
60 "capabilities": { "tools": {} },
61 "serverInfo": { "name": "g1t", "version": "0.1.0" },
62 "instructions": INSTRUCTIONS,
63 }),
64 )
65 }
66 "ping" => result(id, json!({})),
67 "tools/list" => {
68 // A caller sees the tools, and the actions of each, that its
69 // token may use.
70 let gate = gate(services, viewer);
71 let tools: Vec<Value> = TOOLS.iter().filter_map(|tool| tool.listed(&gate)).collect();
72 result(id, json!({ "tools": tools }))
73 }
74 "tools/call" => {
75 let name = params["name"].as_str().unwrap_or_default();
76 let arguments = &params["arguments"];
77 let op = match Tool::by_name(name) {
78 Some(tool) => match crate::tools::resolve(tool, arguments) {
79 Ok(op) => op,
80 Err(problem) => {
81 return Ok(Some(result(
82 id,
83 json!({ "content": [{ "type": "text", "text": problem }], "isError": true }),
84 )));
85 }
86 },
87 // A tool per operation, as the server had before its
88 // resource tools. Still answered, no longer listed; never
89 // one offered over REST alone.
90 None => match Op::by_name(name).filter(|op| !crate::tools::REST_ONLY.contains(op)) {
91 Some(op) => op,
92 None => return Ok(Some(error(id, -32602, "Unknown tool."))),
93 },
94 };
95 let outcome = crate::audit::run(op, services, viewer, arguments).await?;
96 // A failed operation is a tool result the model can read and
97 // act on, not a protocol error.
98 let (text, failed) = match outcome {
99 // In `snake_case`, as the REST API answers; the protocol's
100 // own envelope keeps MCP's spelling.
101 Outcome::Ok(value) => (
102 serde_json::to_string_pretty(&g1t_kit::wire::snake_case_keeping(value, op.as_given()))?,
103 false,
104 ),
105 Outcome::Fail(failure) => (failure.message, true),
106 };
107 result(
108 id,
109 json!({ "content": [{ "type": "text", "text": text }], "isError": failed }),
110 )
111 }
112 method => error(id, -32601, &format!("Method not found: {method}")),
113 };
114 Ok(Some(answer))
115}
116
117/// What someone sees when they open the server's address in a browser:
118/// what this is, how to connect, and what it offers.
119fn card(addresses: &crate::addresses::Addresses) -> Value {
120 let tools: Vec<Value> = TOOLS
121 .iter()
122 .map(|tool| {
123 let actions: Vec<&crate::tools::Action> = tool.actions.iter().collect();
124 json!({
125 "name": tool.name,
126 "title": tool.title,
127 "description": tool.description,
128 "actions": tool.actions.iter().map(|action| json!({
129 "name": action.name,
130 "description": action.summary,
131 "operation": action.op.name(),
132 "scope": g1t_contracts::scopes::scope_for(action.op.name()).map(|scope| scope.as_str()),
133 })).collect::<Vec<_>>(),
134 "input_schema": tool.discriminated(&actions),
135 })
136 })
137 .collect();
138 json!({
139 "name": "g1t",
140 "description": "The g1t MCP server: issues, pull requests and sessions for agents.",
141 "endpoint": addresses.mcp,
142 "transport": "streamable-http",
143 "protocol_versions": SUPPORTED_VERSIONS,
144 "connect": format!("claude mcp add --transport http g1t {}", addresses.mcp),
145 "authorization": {
146 "required": true,
147 "oauth_protected_resource": addresses.protected_resource(),
148 "alternative": "Authorization: Bearer <g1t access token>",
149 },
150 "documentation_url": "https://docs.g1t.sh/guides/bring-your-own-agent/",
151 "instructions": INSTRUCTIONS,
152 "tools": tools,
153 })
154}
155
156pub async fn handle(
157 mut request: Request,
158 services: &Services,
159 viewer: &Viewer,
160) -> Result<Response> {
161 if request.method() != Method::Post {
162 // A client asking for a stream of server messages is told there is
163 // none. Anyone else, a person with a browser, gets a description.
164 let wants_stream = request
165 .headers()
166 .get("accept")?
167 .is_some_and(|accept| accept.contains("text/event-stream"));
168 if request.method() == Method::Get && !wants_stream {
169 return Response::from_json(&card(&services.addresses));
170 }
171 let mut response = Response::empty()?.with_status(405);
172 response.headers_mut().set("allow", "GET, POST")?;
173 return Ok(response);
174 }
175 // Calls need a signed-in user. Answering 401 with this header is what
176 // makes a client open the browser to sign in.
177 if viewer.is_none() {
178 let mut response = Response::from_json(&error(
179 &Value::Null,
180 -32001,
181 "Sign in to use the g1t MCP server.",
182 ))?
183 .with_status(401);
184 response
185 .headers_mut()
186 .set("www-authenticate", &services.addresses.mcp_challenge())?;
187 return Ok(response);
188 }
189 let Ok(body) = request.json::<Value>().await else {
190 return Ok(
191 Response::from_json(&error(&Value::Null, -32700, "Parse error"))?.with_status(400),
192 );
193 };
194 let accepted = || Ok(Response::empty()?.with_status(202));
195 match body {
196 Value::Array(batch) => {
197 let mut answers = Vec::new();
198 for request in &batch {
199 answers.extend(answer(services, viewer, request).await?);
200 }
201 if answers.is_empty() {
202 accepted()
203 } else {
204 Response::from_json(&answers)
205 }
206 }
207 single => match answer(services, viewer, &single).await? {
208 Some(answer) => Response::from_json(&answer),
209 None => accepted(),
210 },
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 /// The instructions name every tool, in the order `tools/list` gives
219 /// them, so that no tool goes unmentioned.
220 #[test]
221 fn the_instructions_list_every_tool() {
222 let names: Vec<&str> = TOOLS.iter().map(|tool| tool.name).collect();
223 let listed = format!("each with an `action`: {}.", names.join(", "));
224 assert!(INSTRUCTIONS.contains(&listed), "{listed}");
225 }
226}