Skip to content
211 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 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.
14Tools are resources, each with an `action`: search, repository, issue, pull_request, agent, plan, memory, workflow, secret, security, webhook, access, workspace, customize, account, notifications. 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. 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.
17Hand 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.
18Security: 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.
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; never
88 // one offered over REST alone.
89 None => match Op::by_name(name).filter(|op| !crate::tools::REST_ONLY.contains(op)) {
90 Some(op) => op,
91 None => return Ok(Some(error(id, -32602, "Unknown tool."))),
92 },
93 };
94 let outcome = crate::audit::run(op, services, viewer, arguments).await?;
95 // A failed operation is a tool result the model can read and
96 // act on, not a protocol error.
97 let (text, failed) = match outcome {
98 // In `snake_case`, as the REST API answers; the protocol's
99 // own envelope keeps MCP's spelling.
100 Outcome::Ok(value) => (
101 serde_json::to_string_pretty(&g1t_kit::wire::snake_case_keeping(value, op.as_given()))?,
102 false,
103 ),
104 Outcome::Fail(failure) => (failure.message, true),
105 };
106 result(
107 id,
108 json!({ "content": [{ "type": "text", "text": text }], "isError": failed }),
109 )
110 }
111 method => error(id, -32601, &format!("Method not found: {method}")),
112 };
113 Ok(Some(answer))
114}
115
116/// What someone sees when they open the server's address in a browser:
117/// what this is, how to connect, and what it offers.
118fn card(addresses: &crate::addresses::Addresses) -> Value {
119 let tools: Vec<Value> = TOOLS
120 .iter()
121 .map(|tool| {
122 let actions: Vec<&crate::tools::Action> = tool.actions.iter().collect();
123 json!({
124 "name": tool.name,
125 "title": tool.title,
126 "description": tool.description,
127 "actions": tool.actions.iter().map(|action| json!({
128 "name": action.name,
129 "description": action.summary,
130 "operation": action.op.name(),
131 "scope": g1t_contracts::scopes::scope_for(action.op.name()).map(|scope| scope.as_str()),
132 })).collect::<Vec<_>>(),
133 "input_schema": tool.discriminated(&actions),
134 })
135 })
136 .collect();
137 json!({
138 "name": "g1t",
139 "description": "The g1t MCP server: issues, pull requests and sessions for agents.",
140 "endpoint": addresses.mcp,
141 "transport": "streamable-http",
142 "protocol_versions": SUPPORTED_VERSIONS,
143 "connect": format!("claude mcp add --transport http g1t {}", addresses.mcp),
144 "authorization": {
145 "required": true,
146 "oauth_protected_resource": addresses.protected_resource(),
147 "alternative": "Authorization: Bearer <g1t access token>",
148 },
149 "documentation_url": "https://docs.g1t.sh/guides/bring-your-own-agent/",
150 "instructions": INSTRUCTIONS,
151 "tools": tools,
152 })
153}
154
155pub async fn handle(
156 mut request: Request,
157 services: &Services,
158 viewer: &Viewer,
159) -> Result<Response> {
160 if request.method() != Method::Post {
161 // A client asking for a stream of server messages is told there is
162 // none. Anyone else, a person with a browser, gets a description.
163 let wants_stream = request
164 .headers()
165 .get("accept")?
166 .is_some_and(|accept| accept.contains("text/event-stream"));
167 if request.method() == Method::Get && !wants_stream {
168 return Response::from_json(&card(&services.addresses));
169 }
170 let mut response = Response::empty()?.with_status(405);
171 response.headers_mut().set("allow", "GET, POST")?;
172 return Ok(response);
173 }
174 // Calls need a signed-in user. Answering 401 with this header is what
175 // makes a client open the browser to sign in.
176 if viewer.is_none() {
177 let mut response = Response::from_json(&error(
178 &Value::Null,
179 -32001,
180 "Sign in to use the g1t MCP server.",
181 ))?
182 .with_status(401);
183 response
184 .headers_mut()
185 .set("www-authenticate", &services.addresses.mcp_challenge())?;
186 return Ok(response);
187 }
188 let Ok(body) = request.json::<Value>().await else {
189 return Ok(
190 Response::from_json(&error(&Value::Null, -32700, "Parse error"))?.with_status(400),
191 );
192 };
193 let accepted = || Ok(Response::empty()?.with_status(202));
194 match body {
195 Value::Array(batch) => {
196 let mut answers = Vec::new();
197 for request in &batch {
198 answers.extend(answer(services, viewer, request).await?);
199 }
200 if answers.is_empty() {
201 accepted()
202 } else {
203 Response::from_json(&answers)
204 }
205 }
206 single => match answer(services, viewer, &single).await? {
207 Some(answer) => Response::from_json(&answer),
208 None => accepted(),
209 },
210 }
211}