Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql
at main 270 lines 7.2 kB view raw
1import database/executor.{type Executor} 2import gleam/bit_array 3import gleam/dynamic/decode 4import gleam/erlang/process.{type Subject} 5import gleam/http 6import gleam/json 7import gleam/option.{type Option} 8import gleam/result 9import lib/mcp/protocol 10import lib/mcp/tools 11import lib/mcp/tools/capabilities 12import lib/mcp/tools/graphql as graphql_tools 13import lib/mcp/tools/lexicons as lexicon_tools 14import lib/mcp/tools/oauth as oauth_tools 15import lib/oauth/did_cache 16import wisp 17 18/// Context for MCP requests 19pub type McpContext { 20 McpContext( 21 db: Executor, 22 external_base_url: String, 23 did_cache: Subject(did_cache.Message), 24 signing_key: Option(String), 25 plc_url: String, 26 supported_scopes: List(String), 27 ) 28} 29 30/// Handle MCP requests 31pub fn handle(req: wisp.Request, ctx: McpContext) -> wisp.Response { 32 case req.method { 33 http.Post -> handle_post(req, ctx) 34 _ -> method_not_allowed_response() 35 } 36} 37 38fn handle_post(req: wisp.Request, ctx: McpContext) -> wisp.Response { 39 // Read request body 40 case wisp.read_body_bits(req) { 41 Ok(body) -> { 42 case bit_array.to_string(body) { 43 Ok(body_string) -> handle_json_rpc(body_string, ctx) 44 Error(_) -> 45 error_response(protocol.parse_error, "Invalid UTF-8", protocol.NullId) 46 } 47 } 48 Error(_) -> 49 error_response( 50 protocol.parse_error, 51 "Failed to read body", 52 protocol.NullId, 53 ) 54 } 55} 56 57fn handle_json_rpc(body: String, ctx: McpContext) -> wisp.Response { 58 case protocol.decode_request(body) { 59 Ok(req) -> dispatch_method(req, body, ctx) 60 Error(msg) -> error_response(protocol.parse_error, msg, protocol.NullId) 61 } 62} 63 64fn dispatch_method( 65 req: protocol.McpRequest, 66 raw_body: String, 67 ctx: McpContext, 68) -> wisp.Response { 69 case req.method { 70 "initialize" -> handle_initialize(req.id) 71 "tools/list" -> handle_tools_list(req.id) 72 "tools/call" -> handle_tools_call(raw_body, req.id, ctx) 73 _ -> 74 error_response( 75 protocol.method_not_found, 76 "Unknown method: " <> req.method, 77 req.id, 78 ) 79 } 80} 81 82fn handle_initialize(id: protocol.RequestId) -> wisp.Response { 83 let result = 84 json.object([ 85 #( 86 "serverInfo", 87 json.object([ 88 #("name", json.string("quickslice")), 89 #("version", json.string("0.1.0")), 90 ]), 91 ), 92 #("capabilities", json.object([#("tools", json.object([]))])), 93 #("protocolVersion", json.string("2024-11-05")), 94 ]) 95 96 success_response(result, id) 97} 98 99fn handle_tools_list(id: protocol.RequestId) -> wisp.Response { 100 let result = tools.encode_tools_list() 101 success_response(result, id) 102} 103 104fn handle_tools_call( 105 raw_body: String, 106 id: protocol.RequestId, 107 ctx: McpContext, 108) -> wisp.Response { 109 // Extract tool name from params 110 let name_decoder = { 111 use params <- decode.field("params", { 112 use name <- decode.field("name", decode.string) 113 decode.success(name) 114 }) 115 decode.success(params) 116 } 117 118 case json.parse(raw_body, name_decoder) { 119 Ok(tool_name) -> execute_tool(tool_name, raw_body, id, ctx) 120 Error(_) -> error_response(protocol.invalid_params, "Missing tool name", id) 121 } 122} 123 124fn execute_tool( 125 tool_name: String, 126 raw_body: String, 127 id: protocol.RequestId, 128 ctx: McpContext, 129) -> wisp.Response { 130 let tool_result = case tool_name { 131 "list_lexicons" -> lexicon_tools.list_lexicons(ctx.db) 132 "get_lexicon" -> { 133 case extract_string_arg(raw_body, "nsid") { 134 Ok(nsid) -> lexicon_tools.get_lexicon(ctx.db, nsid) 135 Error(_) -> Error("get_lexicon requires 'nsid' argument") 136 } 137 } 138 "list_queries" -> { 139 // Return a simple description for now 140 Ok( 141 json.object([ 142 #( 143 "description", 144 json.string( 145 "Use introspect_schema to see available queries, or execute_query to run GraphQL queries", 146 ), 147 ), 148 ]), 149 ) 150 } 151 "get_oauth_info" -> 152 oauth_tools.get_oauth_info(ctx.external_base_url, ctx.supported_scopes) 153 "get_server_capabilities" -> capabilities.get_server_capabilities(ctx.db) 154 "execute_query" -> { 155 case extract_string_arg(raw_body, "query") { 156 Ok(query) -> { 157 let variables = 158 extract_string_arg(raw_body, "variables") 159 |> result.unwrap("{}") 160 graphql_tools.execute_query( 161 ctx.db, 162 query, 163 variables, 164 ctx.did_cache, 165 ctx.signing_key, 166 ctx.plc_url, 167 ) 168 } 169 Error(_) -> Error("execute_query requires 'query' argument") 170 } 171 } 172 "introspect_schema" -> 173 graphql_tools.introspect_schema( 174 ctx.db, 175 ctx.did_cache, 176 ctx.signing_key, 177 ctx.plc_url, 178 ) 179 _ -> Error("Unknown tool: " <> tool_name) 180 } 181 182 case tool_result { 183 Ok(result_json) -> { 184 // Wrap in MCP tool result format 185 let content = 186 json.object([ 187 #( 188 "content", 189 json.array( 190 [ 191 json.object([ 192 #("type", json.string("text")), 193 #("text", json.string(json.to_string(result_json))), 194 ]), 195 ], 196 fn(x) { x }, 197 ), 198 ), 199 ]) 200 success_response(content, id) 201 } 202 Error(msg) -> { 203 // Return tool error (not JSON-RPC error) 204 let content = 205 json.object([ 206 #("isError", json.bool(True)), 207 #( 208 "content", 209 json.array( 210 [ 211 json.object([ 212 #("type", json.string("text")), 213 #("text", json.string(msg)), 214 ]), 215 ], 216 fn(x) { x }, 217 ), 218 ), 219 ]) 220 success_response(content, id) 221 } 222 } 223} 224 225// Response helpers 226 227fn success_response(result: json.Json, id: protocol.RequestId) -> wisp.Response { 228 let body = protocol.encode_response(result, id) 229 wisp.response(200) 230 |> wisp.set_header("content-type", "application/json") 231 |> wisp.set_body(wisp.Text(body)) 232} 233 234fn error_response( 235 code: Int, 236 message: String, 237 id: protocol.RequestId, 238) -> wisp.Response { 239 let body = protocol.encode_error(code, message, id) 240 wisp.response(200) 241 // JSON-RPC errors still return 200 242 |> wisp.set_header("content-type", "application/json") 243 |> wisp.set_body(wisp.Text(body)) 244} 245 246fn method_not_allowed_response() -> wisp.Response { 247 wisp.response(405) 248 |> wisp.set_header("content-type", "application/json") 249 |> wisp.set_body(wisp.Text("{\"error\": \"Method not allowed\"}")) 250} 251 252/// Extract a string argument from the raw JSON body 253/// Looks for params.arguments.{key} 254fn extract_string_arg(raw_body: String, key: String) -> Result(String, Nil) { 255 let decoder = { 256 use params <- decode.field("params", { 257 use arguments <- decode.field("arguments", { 258 use value <- decode.field(key, decode.string) 259 decode.success(value) 260 }) 261 decode.success(arguments) 262 }) 263 decode.success(params) 264 } 265 266 case json.parse(raw_body, decoder) { 267 Ok(value) -> Ok(value) 268 Error(_) -> Error(Nil) 269 } 270}