| 1 | //! MCP over streamable HTTP. The server keeps no session state, so every |
| 2 | //! POST is answered directly with JSON. |
| 3 | |
| 4 | use g1t_contracts::{Outcome, Viewer}; |
| 5 | use serde_json::{Value, json}; |
| 6 | use worker::{Method, Request, Response, Result}; |
| 7 | |
| 8 | use crate::operations::{Op, Services}; |
| 9 | use crate::tools::{Gate, TOOLS, Tool}; |
| 10 | |
| 11 | const SUPPORTED_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"]; |
| 12 | |
| 13 | const INSTRUCTIONS: &str = "g1t is where your code, its history and the work around it live: repositories, work items, 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\" and belong to a workspace. A work item is any tracked work (a feature, a bug, a chore) of a workspace, linked to the repositories it concerns: where it was filed (origin), where the work is meant to land (targets) and where it touched. A workspace numbers its work items and new pull requests in one sequence; pull requests live in their repository. |
| 14 | Tools are resources, each with an `action`: search, repository, work_item, pull_request, session, workflow, observability, package, secret, webhook, access, team, workspace, ai, 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. |
| 15 | Find a repository: account whoami lists your workspaces; repository list or search finds one. Find work: work_item list in a workspace (or with repo, the work items linked to it). |
| 16 | Work on a work item: work_item work (everything about it: what done means, each repository with its pull requests and whether the work landed there, the targets still missing and suggested ones, who is on it, what it waits on), work_item claim (advisory: it tells you who else is on it), then pull_request create in a target repository with the work item's number as `item`: 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. Merging lands the work item in that repository; it closes once it has landed in every target (or, with none, once anything that closes it lands). A commit pushed straight to a default branch lands it only when its message says `Closes #N` (or Fixes, Resolves), never with a bare `(#N)`; `Part of #N` ties work to it without closing it; and work_item close with commit (and repo) lands one after the fact. When the work must land in another repository too, work_item link it as a target. |
| 17 | Coordinate: session start once per run of your harness (harness_session_id makes it idempotent), session update as a heartbeat, session get between work steps to read pending updates. Reading does not acknowledge: consider relevant changes, record a public decision summary, then session record with acknowledge containing only the exact update IDs considered. Keep your own session ID explicit, especially when sharing a checkout. Separate scopes may proceed concurrently; agree ownership before overlapping edits. session end when done; work_item release or handoff (with a note) when you stop. |
| 18 | Record public summaries only, never private chain-of-thought, credentials or secrets. As you work, pull_request record_session appends user-facing decisions, evidence and tool activity 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. |
| 19 | Merge 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 work item and its session, and as integration.failed). Fix it and push: once its checks pass, it rejoins the queue by itself. |
| 20 | Security: 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. |
| 21 | Production: observability overview and errors show what a project's running software did; follow a request or trace to its logs."; |
| 22 | |
| 23 | fn result(id: &Value, value: Value) -> Value { |
| 24 | json!({ "jsonrpc": "2.0", "id": id, "result": value }) |
| 25 | } |
| 26 | |
| 27 | fn 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. |
| 33 | fn 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. |
| 44 | async 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 = ¶ms["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 arguments = &crate::tools::with_defaults(op, arguments); |
| 97 | let outcome = crate::audit::run(op, services, viewer, arguments).await?; |
| 98 | // A failed operation is a tool result the model can read and |
| 99 | // act on, not a protocol error. |
| 100 | let (text, failed) = match outcome { |
| 101 | // In `snake_case`, as the REST API answers; the protocol's |
| 102 | // own envelope keeps MCP's spelling. |
| 103 | Outcome::Ok(value) => ( |
| 104 | serde_json::to_string_pretty(&g1t_kit::wire::snake_case_keeping(value, op.as_given()))?, |
| 105 | false, |
| 106 | ), |
| 107 | Outcome::Fail(failure) => (failure.message, true), |
| 108 | }; |
| 109 | result( |
| 110 | id, |
| 111 | json!({ "content": [{ "type": "text", "text": text }], "isError": failed }), |
| 112 | ) |
| 113 | } |
| 114 | method => error(id, -32601, &format!("Method not found: {method}")), |
| 115 | }; |
| 116 | Ok(Some(answer)) |
| 117 | } |
| 118 | |
| 119 | /// What someone sees when they open the server's address in a browser: |
| 120 | /// what this is, how to connect, and what it offers. |
| 121 | fn card(addresses: &crate::addresses::Addresses) -> Value { |
| 122 | let tools: Vec<Value> = TOOLS |
| 123 | .iter() |
| 124 | .map(|tool| { |
| 125 | let actions: Vec<&crate::tools::Action> = tool.actions.iter().collect(); |
| 126 | json!({ |
| 127 | "name": tool.name, |
| 128 | "title": tool.title, |
| 129 | "description": tool.description, |
| 130 | "actions": tool.actions.iter().map(|action| json!({ |
| 131 | "name": action.name, |
| 132 | "description": action.summary, |
| 133 | "operation": action.op.name(), |
| 134 | "scope": g1t_contracts::scopes::scope_for(action.op.name()).map(|scope| scope.as_str()), |
| 135 | })).collect::<Vec<_>>(), |
| 136 | "input_schema": tool.discriminated(&actions), |
| 137 | }) |
| 138 | }) |
| 139 | .collect(); |
| 140 | let mut card = json!({ |
| 141 | "name": "g1t", |
| 142 | "description": "The g1t MCP server: work items, pull requests and sessions for agents.", |
| 143 | "endpoint": addresses.mcp, |
| 144 | "transport": "streamable-http", |
| 145 | "protocol_versions": SUPPORTED_VERSIONS, |
| 146 | // For Claude Code, g1t's plugin comes first: it adds this server |
| 147 | // itself (as plugin:g1t:g1t), so `connect` is for Claude Code |
| 148 | // without the plugin; with both, every tool is listed twice. |
| 149 | "connect": format!("claude mcp add --transport http g1t {}", addresses.mcp), |
| 150 | "claude_code_plugin": { |
| 151 | "install": [ |
| 152 | "claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git", |
| 153 | "claude plugin install g1t@g1t", |
| 154 | ], |
| 155 | "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.", |
| 156 | "documentation_url": "https://docs.g1t.sh/guides/claude-code-plugin/", |
| 157 | }, |
| 158 | "authorization": { |
| 159 | "required": true, |
| 160 | "oauth_protected_resource": addresses.protected_resource(), |
| 161 | "alternative": "Authorization: Bearer <g1t access token>", |
| 162 | }, |
| 163 | "documentation_url": "https://docs.g1t.sh/guides/bring-your-own-agent/", |
| 164 | "instructions": INSTRUCTIONS, |
| 165 | "tools": tools, |
| 166 | }); |
| 167 | // Codex also reads the endpoint itself as protected resource metadata. |
| 168 | // Keep the public card useful to people and valid for OAuth discovery. |
| 169 | if let (Some(card), Value::Object(metadata)) = ( |
| 170 | card.as_object_mut(), |
| 171 | crate::oauth::protected_resource_metadata(addresses), |
| 172 | ) { |
| 173 | card.extend(metadata); |
| 174 | } |
| 175 | card |
| 176 | } |
| 177 | |
| 178 | pub async fn handle( |
| 179 | mut request: Request, |
| 180 | services: &Services, |
| 181 | viewer: &Viewer, |
| 182 | ) -> Result<Response> { |
| 183 | if request.method() != Method::Post { |
| 184 | // A client asking for a stream of server messages is told there is |
| 185 | // none. Anyone else, a person with a browser, gets a description. |
| 186 | let wants_stream = request |
| 187 | .headers() |
| 188 | .get("accept")? |
| 189 | .is_some_and(|accept| accept.contains("text/event-stream")); |
| 190 | if request.method() == Method::Get && !wants_stream { |
| 191 | return Response::from_json(&card(&services.addresses)); |
| 192 | } |
| 193 | let mut response = Response::empty()?.with_status(405); |
| 194 | response.headers_mut().set("allow", "GET, POST")?; |
| 195 | return Ok(response); |
| 196 | } |
| 197 | // Calls need a signed-in user. Answering 401 with this header is what |
| 198 | // makes a client open the browser to sign in. |
| 199 | if viewer.is_none() { |
| 200 | let mut response = Response::from_json(&error( |
| 201 | &Value::Null, |
| 202 | -32001, |
| 203 | "Sign in to use the g1t MCP server.", |
| 204 | ))? |
| 205 | .with_status(401); |
| 206 | response |
| 207 | .headers_mut() |
| 208 | .set("www-authenticate", &services.addresses.mcp_challenge())?; |
| 209 | return Ok(response); |
| 210 | } |
| 211 | let Ok(body) = request.json::<Value>().await else { |
| 212 | return Ok( |
| 213 | Response::from_json(&error(&Value::Null, -32700, "Parse error"))?.with_status(400), |
| 214 | ); |
| 215 | }; |
| 216 | let accepted = || Ok(Response::empty()?.with_status(202)); |
| 217 | match body { |
| 218 | Value::Array(batch) => { |
| 219 | let mut answers = Vec::new(); |
| 220 | for request in &batch { |
| 221 | answers.extend(answer(services, viewer, request).await?); |
| 222 | } |
| 223 | if answers.is_empty() { |
| 224 | accepted() |
| 225 | } else { |
| 226 | Response::from_json(&answers) |
| 227 | } |
| 228 | } |
| 229 | single => match answer(services, viewer, &single).await? { |
| 230 | Some(answer) => Response::from_json(&answer), |
| 231 | None => accepted(), |
| 232 | }, |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | #[cfg(test)] |
| 237 | mod tests { |
| 238 | use super::*; |
| 239 | |
| 240 | #[test] |
| 241 | fn server_card_supports_oauth_discovery() { |
| 242 | use crate::addresses::Addresses; |
| 243 | |
| 244 | for addresses in [ |
| 245 | Addresses::default(), |
| 246 | Addresses::from_settings( |
| 247 | Some("https://forge.example".into()), |
| 248 | Some("https://api.forge.example".into()), |
| 249 | Some("https://api.forge.example/mcp".into()), |
| 250 | ), |
| 251 | ] { |
| 252 | let card = card(&addresses); |
| 253 | assert_eq!(card["resource"], addresses.mcp); |
| 254 | assert_eq!(card["authorization_servers"], json!([addresses.api])); |
| 255 | assert_eq!(card["bearer_methods_supported"], json!(["header"])); |
| 256 | assert!(card["scopes_supported"].as_array().is_some_and(|scopes| !scopes.is_empty())); |
| 257 | for (key, value) in crate::oauth::protected_resource_metadata(&addresses).as_object().unwrap() { |
| 258 | assert_eq!(&card[key], value, "discovery field {key}"); |
| 259 | } |
| 260 | assert_eq!(card["endpoint"], addresses.mcp); |
| 261 | assert!(card["tools"].as_array().is_some_and(|tools| !tools.is_empty())); |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | /// The instructions name every tool, in the order `tools/list` gives |
| 266 | /// them, so that no tool goes unmentioned. |
| 267 | #[test] |
| 268 | fn the_instructions_list_every_tool() { |
| 269 | let names: Vec<&str> = TOOLS.iter().map(|tool| tool.name).collect(); |
| 270 | let listed = format!("each with an `action`: {}.", names.join(", ")); |
| 271 | assert!(INSTRUCTIONS.contains(&listed), "{listed}"); |
| 272 | } |
| 273 | } |