Auto-indexing service and GraphQL API for AT Protocol Records
quickslice.slices.network/
atproto
gleam
graphql
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}