Skip to content

g1t/apps/api/src/mcp.rs

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