Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql

feat(mcp): add Model Context Protocol server

Implements an MCP HTTP server that exposes QuickSlice's AT Protocol
capabilities to AI assistants like Claude.

Tools provided:
- list_lexicons: Browse all registered lexicons
- get_lexicon: Get full lexicon definition by NSID
- list_queries: List available GraphQL queries
- get_oauth_info: Get OAuth flows, scopes, and endpoints
- get_server_capabilities: Get server version and features
- execute_query: Run GraphQL queries against the schema
- introspect_schema: Get full GraphQL schema introspection

Includes JSON-RPC protocol implementation, tool registry, HTTP handler,
integration tests, and usage documentation.

+2996
+63
docs/mcp.md
··· 1 + # MCP Server 2 + 3 + Quickslice provides an MCP (Model Context Protocol) server that lets AI assistants query your ATProto data directly. 4 + 5 + ## Endpoint 6 + 7 + ``` 8 + POST http://localhost:8080/mcp 9 + ``` 10 + 11 + ## Setup 12 + 13 + 1. Start the quickslice server: 14 + ```bash 15 + cd server && gleam run 16 + ``` 17 + 18 + 2. Connect your MCP client to `http://localhost:8080/mcp` 19 + 20 + ### Claude Code 21 + 22 + ```bash 23 + claude mcp add --transport http --scope user quickslice http://localhost:8080/mcp 24 + ``` 25 + 26 + ### Claude Desktop 27 + 28 + Add to your `claude_desktop_config.json`: 29 + ```json 30 + { 31 + "mcpServers": { 32 + "quickslice": { 33 + "url": "http://localhost:8080/mcp" 34 + } 35 + } 36 + } 37 + ``` 38 + 39 + ### Other MCP Clients 40 + 41 + Point any MCP-compatible client at the `/mcp` endpoint using HTTP transport. 42 + 43 + ## Available Tools 44 + 45 + | Tool | Description | 46 + |------|-------------| 47 + | `list_lexicons` | List all registered lexicons | 48 + | `get_lexicon` | Get full lexicon definition by NSID | 49 + | `list_queries` | List available GraphQL queries | 50 + | `get_oauth_info` | Get OAuth flows, scopes, and endpoints | 51 + | `get_server_capabilities` | Get server version and features | 52 + | `introspect_schema` | Get full GraphQL schema | 53 + | `execute_query` | Execute a GraphQL query | 54 + 55 + ## Example Prompts 56 + 57 + Once connected, you can ask things like: 58 + 59 + - "What lexicons are registered?" 60 + - "Show me the schema for xyz.statusphere.status" 61 + - "Query the latest 10 statusphere statuses" 62 + - "What GraphQL queries are available?" 63 + - "What OAuth scopes does this server support?"
+1706
docs/plans/2025-12-01-mcp-server.md
··· 1 + # MCP Server Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add an MCP (Model Context Protocol) server endpoint to quickslice that exposes lexicons, GraphQL queries, OAuth info, and server capabilities for AI assistant introspection. 6 + 7 + **Architecture:** Embed MCP as a stateless HTTP endpoint (`POST /mcp`) using JSON-RPC 2.0. Tools query existing systems (lexicons repo, GraphQL schema, OAuth config) and return structured responses. No new data storage needed. 8 + 9 + **Tech Stack:** Gleam, wisp (HTTP), gleam_json, existing swell GraphQL library 10 + 11 + --- 12 + 13 + ## Task 1: Create MCP Protocol Types 14 + 15 + **Files:** 16 + - Create: `server/src/lib/mcp/protocol.gleam` 17 + - Test: `server/test/mcp/protocol_test.gleam` 18 + 19 + **Step 1: Write the failing test for JSON-RPC request decoding** 20 + 21 + ```gleam 22 + // server/test/mcp/protocol_test.gleam 23 + import gleam/json 24 + import gleeunit/should 25 + import lib/mcp/protocol 26 + 27 + pub fn decode_initialize_request_test() { 28 + let json_str = 29 + "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"params\":{},\"id\":1}" 30 + 31 + let result = protocol.decode_request(json_str) 32 + 33 + result |> should.be_ok 34 + let assert Ok(req) = result 35 + req.method |> should.equal("initialize") 36 + req.id |> should.equal(protocol.IntId(1)) 37 + } 38 + 39 + pub fn decode_tools_list_request_test() { 40 + let json_str = "{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"id\":2}" 41 + 42 + let result = protocol.decode_request(json_str) 43 + 44 + result |> should.be_ok 45 + let assert Ok(req) = result 46 + req.method |> should.equal("tools/list") 47 + } 48 + 49 + pub fn decode_tools_call_request_test() { 50 + let json_str = 51 + "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"list_lexicons\",\"arguments\":{}},\"id\":3}" 52 + 53 + let result = protocol.decode_request(json_str) 54 + 55 + result |> should.be_ok 56 + let assert Ok(req) = result 57 + req.method |> should.equal("tools/call") 58 + } 59 + ``` 60 + 61 + **Step 2: Run test to verify it fails** 62 + 63 + Run: `cd server && gleam test -- --only mcp` 64 + Expected: Compile error - module `lib/mcp/protocol` not found 65 + 66 + **Step 3: Write the protocol types and decoder** 67 + 68 + ```gleam 69 + // server/src/lib/mcp/protocol.gleam 70 + import gleam/dynamic/decode 71 + import gleam/json 72 + import gleam/option.{type Option, None, Some} 73 + import gleam/result 74 + 75 + /// JSON-RPC request ID can be string or int 76 + pub type RequestId { 77 + StringId(String) 78 + IntId(Int) 79 + NullId 80 + } 81 + 82 + /// Decoded MCP request 83 + pub type McpRequest { 84 + McpRequest( 85 + jsonrpc: String, 86 + method: String, 87 + params: Option(json.Json), 88 + id: RequestId, 89 + ) 90 + } 91 + 92 + /// MCP response (success case) 93 + pub type McpResponse { 94 + McpResponse(jsonrpc: String, result: json.Json, id: RequestId) 95 + } 96 + 97 + /// MCP error response 98 + pub type McpErrorResponse { 99 + McpErrorResponse(jsonrpc: String, error: McpError, id: RequestId) 100 + } 101 + 102 + /// JSON-RPC error object 103 + pub type McpError { 104 + McpError(code: Int, message: String) 105 + } 106 + 107 + /// Standard JSON-RPC error codes 108 + pub const parse_error = -32_700 109 + 110 + pub const invalid_request = -32_600 111 + 112 + pub const method_not_found = -32_601 113 + 114 + pub const invalid_params = -32_602 115 + 116 + pub const internal_error = -32_603 117 + 118 + /// Decode a JSON-RPC request from string 119 + pub fn decode_request(json_str: String) -> Result(McpRequest, String) { 120 + let id_decoder = 121 + decode.one_of(decode.int |> decode.map(IntId), [ 122 + decode.string |> decode.map(StringId), 123 + decode.null |> decode.map(fn(_) { NullId }), 124 + ]) 125 + 126 + let decoder = { 127 + use jsonrpc <- decode.field("jsonrpc", decode.string) 128 + use method <- decode.field("method", decode.string) 129 + use id <- decode.field("id", id_decoder) 130 + decode.success(McpRequest(jsonrpc:, method:, params: None, id:)) 131 + } 132 + 133 + json.parse(json_str, decoder) 134 + |> result.map_error(fn(_) { "Failed to parse JSON-RPC request" }) 135 + } 136 + 137 + /// Encode a success response 138 + pub fn encode_response(result: json.Json, id: RequestId) -> String { 139 + let id_json = case id { 140 + IntId(i) -> json.int(i) 141 + StringId(s) -> json.string(s) 142 + NullId -> json.null() 143 + } 144 + 145 + json.object([ 146 + #("jsonrpc", json.string("2.0")), 147 + #("result", result), 148 + #("id", id_json), 149 + ]) 150 + |> json.to_string 151 + } 152 + 153 + /// Encode an error response 154 + pub fn encode_error(code: Int, message: String, id: RequestId) -> String { 155 + let id_json = case id { 156 + IntId(i) -> json.int(i) 157 + StringId(s) -> json.string(s) 158 + NullId -> json.null() 159 + } 160 + 161 + json.object([ 162 + #("jsonrpc", json.string("2.0")), 163 + #("error", json.object([#("code", json.int(code)), #("message", json.string(message))])), 164 + #("id", id_json), 165 + ]) 166 + |> json.to_string 167 + } 168 + ``` 169 + 170 + **Step 4: Run test to verify it passes** 171 + 172 + Run: `cd server && gleam test -- --only mcp` 173 + Expected: All tests pass 174 + 175 + **Step 5: Commit** 176 + 177 + ```bash 178 + git add server/src/lib/mcp/protocol.gleam server/test/mcp/protocol_test.gleam 179 + git commit -m "feat(mcp): add JSON-RPC protocol types and decoder" 180 + ``` 181 + 182 + --- 183 + 184 + ## Task 2: Create Tool Definitions 185 + 186 + **Files:** 187 + - Create: `server/src/lib/mcp/tools.gleam` 188 + - Test: `server/test/mcp/tools_test.gleam` 189 + 190 + **Step 1: Write the failing test for tool registry** 191 + 192 + ```gleam 193 + // server/test/mcp/tools_test.gleam 194 + import gleam/list 195 + import gleeunit/should 196 + import lib/mcp/tools 197 + 198 + pub fn list_tools_returns_all_tools_test() { 199 + let tool_list = tools.list_tools() 200 + 201 + // Should have 7 tools 202 + list.length(tool_list) |> should.equal(7) 203 + } 204 + 205 + pub fn list_tools_has_list_lexicons_test() { 206 + let tool_list = tools.list_tools() 207 + 208 + let has_list_lexicons = 209 + list.any(tool_list, fn(t) { t.name == "list_lexicons" }) 210 + has_list_lexicons |> should.be_true 211 + } 212 + 213 + pub fn get_tool_returns_tool_test() { 214 + let result = tools.get_tool("list_lexicons") 215 + 216 + result |> should.be_ok 217 + let assert Ok(tool) = result 218 + tool.name |> should.equal("list_lexicons") 219 + } 220 + 221 + pub fn get_tool_returns_error_for_unknown_test() { 222 + let result = tools.get_tool("unknown_tool") 223 + 224 + result |> should.be_error 225 + } 226 + ``` 227 + 228 + **Step 2: Run test to verify it fails** 229 + 230 + Run: `cd server && gleam test -- --only tools` 231 + Expected: Compile error - module `lib/mcp/tools` not found 232 + 233 + **Step 3: Write the tool registry** 234 + 235 + ```gleam 236 + // server/src/lib/mcp/tools.gleam 237 + import gleam/json 238 + import gleam/list 239 + import gleam/result 240 + 241 + /// Tool definition for MCP 242 + pub type Tool { 243 + Tool(name: String, description: String, input_schema: json.Json) 244 + } 245 + 246 + /// Get all available tools 247 + pub fn list_tools() -> List(Tool) { 248 + [ 249 + Tool( 250 + name: "list_lexicons", 251 + description: "List all registered lexicons with their NSIDs and types", 252 + input_schema: json.object([ 253 + #("type", json.string("object")), 254 + #("properties", json.object([])), 255 + ]), 256 + ), 257 + Tool( 258 + name: "get_lexicon", 259 + description: "Get full lexicon definition by NSID", 260 + input_schema: json.object([ 261 + #("type", json.string("object")), 262 + #( 263 + "properties", 264 + json.object([ 265 + #( 266 + "nsid", 267 + json.object([ 268 + #("type", json.string("string")), 269 + #("description", json.string("Lexicon NSID (e.g., app.bsky.feed.post)")), 270 + ]), 271 + ), 272 + ]), 273 + ), 274 + #("required", json.array(["nsid"], json.string)), 275 + ]), 276 + ), 277 + Tool( 278 + name: "list_queries", 279 + description: "List available GraphQL queries and their arguments", 280 + input_schema: json.object([ 281 + #("type", json.string("object")), 282 + #("properties", json.object([])), 283 + ]), 284 + ), 285 + Tool( 286 + name: "get_oauth_info", 287 + description: "Get supported OAuth flows, scopes, and endpoints", 288 + input_schema: json.object([ 289 + #("type", json.string("object")), 290 + #("properties", json.object([])), 291 + ]), 292 + ), 293 + Tool( 294 + name: "get_server_capabilities", 295 + description: "Get server capabilities, version, and features", 296 + input_schema: json.object([ 297 + #("type", json.string("object")), 298 + #("properties", json.object([])), 299 + ]), 300 + ), 301 + Tool( 302 + name: "execute_query", 303 + description: "Execute a GraphQL query", 304 + input_schema: json.object([ 305 + #("type", json.string("object")), 306 + #( 307 + "properties", 308 + json.object([ 309 + #( 310 + "query", 311 + json.object([ 312 + #("type", json.string("string")), 313 + #("description", json.string("GraphQL query string")), 314 + ]), 315 + ), 316 + #( 317 + "variables", 318 + json.object([ 319 + #("type", json.string("object")), 320 + #("description", json.string("Query variables (optional)")), 321 + ]), 322 + ), 323 + ]), 324 + ), 325 + #("required", json.array(["query"], json.string)), 326 + ]), 327 + ), 328 + Tool( 329 + name: "introspect_schema", 330 + description: "Get full GraphQL schema introspection", 331 + input_schema: json.object([ 332 + #("type", json.string("object")), 333 + #("properties", json.object([])), 334 + ]), 335 + ), 336 + ] 337 + } 338 + 339 + /// Get a tool by name 340 + pub fn get_tool(name: String) -> Result(Tool, String) { 341 + list_tools() 342 + |> list.find(fn(t) { t.name == name }) 343 + |> result.replace_error("Tool not found: " <> name) 344 + } 345 + 346 + /// Encode tools list as MCP format 347 + pub fn encode_tools_list() -> json.Json { 348 + json.object([ 349 + #( 350 + "tools", 351 + json.array(list_tools(), fn(tool) { 352 + json.object([ 353 + #("name", json.string(tool.name)), 354 + #("description", json.string(tool.description)), 355 + #("inputSchema", tool.input_schema), 356 + ]) 357 + }), 358 + ), 359 + ]) 360 + } 361 + ``` 362 + 363 + **Step 4: Run test to verify it passes** 364 + 365 + Run: `cd server && gleam test -- --only tools` 366 + Expected: All tests pass 367 + 368 + **Step 5: Commit** 369 + 370 + ```bash 371 + git add server/src/lib/mcp/tools.gleam server/test/mcp/tools_test.gleam 372 + git commit -m "feat(mcp): add tool registry with 7 tool definitions" 373 + ``` 374 + 375 + --- 376 + 377 + ## Task 3: Implement Lexicon Tools 378 + 379 + **Files:** 380 + - Create: `server/src/lib/mcp/tools/lexicons.gleam` 381 + - Test: `server/test/mcp/tools/lexicons_test.gleam` 382 + 383 + **Step 1: Write the failing test** 384 + 385 + ```gleam 386 + // server/test/mcp/tools/lexicons_test.gleam 387 + import database/connection 388 + import database/repositories/lexicons 389 + import gleam/json 390 + import gleam/string 391 + import gleeunit/should 392 + import lib/mcp/tools/lexicons as lexicon_tools 393 + import sqlight 394 + 395 + pub fn list_lexicons_returns_array_test() { 396 + // Set up in-memory database 397 + let assert Ok(db) = sqlight.open(":memory:") 398 + let assert Ok(_) = connection.run_migrations(db) 399 + 400 + // Insert a test lexicon 401 + let lexicon_json = 402 + "{\"lexicon\":1,\"id\":\"test.example.record\",\"defs\":{\"main\":{\"type\":\"record\"}}}" 403 + let assert Ok(_) = lexicons.insert(db, "test.example.record", lexicon_json) 404 + 405 + // Call the tool 406 + let result = lexicon_tools.list_lexicons(db) 407 + 408 + result |> should.be_ok 409 + let assert Ok(json_result) = result 410 + let json_str = json.to_string(json_result) 411 + 412 + // Should contain the lexicon NSID 413 + string.contains(json_str, "test.example.record") |> should.be_true 414 + } 415 + 416 + pub fn get_lexicon_returns_full_definition_test() { 417 + // Set up in-memory database 418 + let assert Ok(db) = sqlight.open(":memory:") 419 + let assert Ok(_) = connection.run_migrations(db) 420 + 421 + // Insert a test lexicon 422 + let lexicon_json = 423 + "{\"lexicon\":1,\"id\":\"test.example.record\",\"defs\":{\"main\":{\"type\":\"record\"}}}" 424 + let assert Ok(_) = lexicons.insert(db, "test.example.record", lexicon_json) 425 + 426 + // Call the tool 427 + let result = lexicon_tools.get_lexicon(db, "test.example.record") 428 + 429 + result |> should.be_ok 430 + let assert Ok(json_result) = result 431 + let json_str = json.to_string(json_result) 432 + 433 + // Should contain the full lexicon definition 434 + string.contains(json_str, "\"type\":\"record\"") |> should.be_true 435 + } 436 + 437 + pub fn get_lexicon_not_found_test() { 438 + let assert Ok(db) = sqlight.open(":memory:") 439 + let assert Ok(_) = connection.run_migrations(db) 440 + 441 + let result = lexicon_tools.get_lexicon(db, "nonexistent.lexicon") 442 + 443 + result |> should.be_error 444 + } 445 + ``` 446 + 447 + **Step 2: Run test to verify it fails** 448 + 449 + Run: `cd server && gleam test -- --only lexicons_test` 450 + Expected: Compile error - module `lib/mcp/tools/lexicons` not found 451 + 452 + **Step 3: Write the lexicon tools** 453 + 454 + ```gleam 455 + // server/src/lib/mcp/tools/lexicons.gleam 456 + import database/repositories/lexicons 457 + import gleam/dynamic/decode 458 + import gleam/json 459 + import gleam/list 460 + import gleam/result 461 + import sqlight 462 + 463 + /// List all lexicons with summary info 464 + pub fn list_lexicons(db: sqlight.Connection) -> Result(json.Json, String) { 465 + use lexicon_list <- result.try( 466 + lexicons.get_all(db) 467 + |> result.map_error(fn(_) { "Failed to fetch lexicons" }), 468 + ) 469 + 470 + let summaries = 471 + list.map(lexicon_list, fn(lex) { 472 + // Extract type from JSON 473 + let lexicon_type = extract_lexicon_type(lex.json) 474 + 475 + json.object([ 476 + #("nsid", json.string(lex.id)), 477 + #("type", json.string(lexicon_type)), 478 + #("createdAt", json.string(lex.created_at)), 479 + ]) 480 + }) 481 + 482 + Ok(json.object([#("lexicons", json.array(summaries, fn(x) { x }))])) 483 + } 484 + 485 + /// Get a single lexicon by NSID 486 + pub fn get_lexicon( 487 + db: sqlight.Connection, 488 + nsid: String, 489 + ) -> Result(json.Json, String) { 490 + use lexicon_list <- result.try( 491 + lexicons.get(db, nsid) 492 + |> result.map_error(fn(_) { "Failed to fetch lexicon" }), 493 + ) 494 + 495 + case lexicon_list { 496 + [] -> Error("Lexicon not found: " <> nsid) 497 + [lex, ..] -> { 498 + // Parse the stored JSON and return it 499 + case json.parse(lex.json, decode.dynamic) { 500 + Ok(_) -> 501 + Ok( 502 + json.object([ 503 + #("nsid", json.string(lex.id)), 504 + #("definition", json.preprocessed_array(lex.json)), 505 + ]), 506 + ) 507 + Error(_) -> Error("Failed to parse lexicon JSON") 508 + } 509 + } 510 + } 511 + } 512 + 513 + /// Extract the main type from a lexicon JSON string 514 + fn extract_lexicon_type(json_str: String) -> String { 515 + // Simple extraction - look for "type" in defs.main 516 + let decoder = { 517 + use defs <- decode.field("defs", { 518 + use main <- decode.field("main", { 519 + use t <- decode.field("type", decode.string) 520 + decode.success(t) 521 + }) 522 + decode.success(main) 523 + }) 524 + decode.success(defs) 525 + } 526 + 527 + case json.parse(json_str, decoder) { 528 + Ok(t) -> t 529 + Error(_) -> "unknown" 530 + } 531 + } 532 + ``` 533 + 534 + **Step 4: Run test to verify it passes** 535 + 536 + Run: `cd server && gleam test -- --only lexicons_test` 537 + Expected: All tests pass 538 + 539 + **Step 5: Commit** 540 + 541 + ```bash 542 + git add server/src/lib/mcp/tools/lexicons.gleam server/test/mcp/tools/lexicons_test.gleam 543 + git commit -m "feat(mcp): add list_lexicons and get_lexicon tool implementations" 544 + ``` 545 + 546 + --- 547 + 548 + ## Task 4: Implement OAuth Info Tool 549 + 550 + **Files:** 551 + - Create: `server/src/lib/mcp/tools/oauth.gleam` 552 + - Test: `server/test/mcp/tools/oauth_test.gleam` 553 + 554 + **Step 1: Write the failing test** 555 + 556 + ```gleam 557 + // server/test/mcp/tools/oauth_test.gleam 558 + import gleam/json 559 + import gleam/string 560 + import gleeunit/should 561 + import lib/mcp/tools/oauth as oauth_tools 562 + 563 + pub fn get_oauth_info_returns_flows_test() { 564 + let result = 565 + oauth_tools.get_oauth_info( 566 + "https://example.com", 567 + ["atproto", "transition:generic"], 568 + ) 569 + 570 + result |> should.be_ok 571 + let assert Ok(json_result) = result 572 + let json_str = json.to_string(json_result) 573 + 574 + // Should contain authorization_code flow 575 + string.contains(json_str, "authorization_code") |> should.be_true 576 + } 577 + 578 + pub fn get_oauth_info_returns_endpoints_test() { 579 + let result = 580 + oauth_tools.get_oauth_info( 581 + "https://example.com", 582 + ["atproto", "transition:generic"], 583 + ) 584 + 585 + result |> should.be_ok 586 + let assert Ok(json_result) = result 587 + let json_str = json.to_string(json_result) 588 + 589 + // Should contain endpoints 590 + string.contains(json_str, "/oauth/authorize") |> should.be_true 591 + string.contains(json_str, "/oauth/token") |> should.be_true 592 + } 593 + 594 + pub fn get_oauth_info_returns_scopes_test() { 595 + let result = 596 + oauth_tools.get_oauth_info( 597 + "https://example.com", 598 + ["atproto", "custom:scope"], 599 + ) 600 + 601 + result |> should.be_ok 602 + let assert Ok(json_result) = result 603 + let json_str = json.to_string(json_result) 604 + 605 + // Should contain the scopes 606 + string.contains(json_str, "atproto") |> should.be_true 607 + string.contains(json_str, "custom:scope") |> should.be_true 608 + } 609 + ``` 610 + 611 + **Step 2: Run test to verify it fails** 612 + 613 + Run: `cd server && gleam test -- --only oauth_test` 614 + Expected: Compile error - module `lib/mcp/tools/oauth` not found 615 + 616 + **Step 3: Write the OAuth info tool** 617 + 618 + ```gleam 619 + // server/src/lib/mcp/tools/oauth.gleam 620 + import gleam/json 621 + 622 + /// Get OAuth configuration info 623 + pub fn get_oauth_info( 624 + base_url: String, 625 + supported_scopes: List(String), 626 + ) -> Result(json.Json, String) { 627 + Ok( 628 + json.object([ 629 + #( 630 + "flows", 631 + json.array(["authorization_code", "refresh_token"], json.string), 632 + ), 633 + #("scopes", json.array(supported_scopes, json.string)), 634 + #( 635 + "endpoints", 636 + json.object([ 637 + #("authorize", json.string(base_url <> "/oauth/authorize")), 638 + #("token", json.string(base_url <> "/oauth/token")), 639 + #("register", json.string(base_url <> "/oauth/register")), 640 + #("par", json.string(base_url <> "/oauth/par")), 641 + #("jwks", json.string(base_url <> "/.well-known/jwks.json")), 642 + #( 643 + "metadata", 644 + json.string(base_url <> "/.well-known/oauth-authorization-server"), 645 + ), 646 + ]), 647 + ), 648 + #("clientTypes", json.array(["public", "confidential"], json.string)), 649 + #( 650 + "authMethods", 651 + json.array(["none", "client_secret_post", "client_secret_basic"], json.string), 652 + ), 653 + #("pkceRequired", json.bool(True)), 654 + #("dpopSupported", json.bool(True)), 655 + ]), 656 + ) 657 + } 658 + ``` 659 + 660 + **Step 4: Run test to verify it passes** 661 + 662 + Run: `cd server && gleam test -- --only oauth_test` 663 + Expected: All tests pass 664 + 665 + **Step 5: Commit** 666 + 667 + ```bash 668 + git add server/src/lib/mcp/tools/oauth.gleam server/test/mcp/tools/oauth_test.gleam 669 + git commit -m "feat(mcp): add get_oauth_info tool implementation" 670 + ``` 671 + 672 + --- 673 + 674 + ## Task 5: Implement Capabilities Tool 675 + 676 + **Files:** 677 + - Create: `server/src/lib/mcp/tools/capabilities.gleam` 678 + - Test: `server/test/mcp/tools/capabilities_test.gleam` 679 + 680 + **Step 1: Write the failing test** 681 + 682 + ```gleam 683 + // server/test/mcp/tools/capabilities_test.gleam 684 + import database/connection 685 + import gleam/json 686 + import gleam/string 687 + import gleeunit/should 688 + import lib/mcp/tools/capabilities 689 + import sqlight 690 + 691 + pub fn get_server_capabilities_returns_version_test() { 692 + let assert Ok(db) = sqlight.open(":memory:") 693 + let assert Ok(_) = connection.run_migrations(db) 694 + 695 + let result = capabilities.get_server_capabilities(db) 696 + 697 + result |> should.be_ok 698 + let assert Ok(json_result) = result 699 + let json_str = json.to_string(json_result) 700 + 701 + // Should contain version 702 + string.contains(json_str, "version") |> should.be_true 703 + } 704 + 705 + pub fn get_server_capabilities_returns_features_test() { 706 + let assert Ok(db) = sqlight.open(":memory:") 707 + let assert Ok(_) = connection.run_migrations(db) 708 + 709 + let result = capabilities.get_server_capabilities(db) 710 + 711 + result |> should.be_ok 712 + let assert Ok(json_result) = result 713 + let json_str = json.to_string(json_result) 714 + 715 + // Should contain features 716 + string.contains(json_str, "graphql") |> should.be_true 717 + string.contains(json_str, "subscriptions") |> should.be_true 718 + } 719 + ``` 720 + 721 + **Step 2: Run test to verify it fails** 722 + 723 + Run: `cd server && gleam test -- --only capabilities` 724 + Expected: Compile error - module `lib/mcp/tools/capabilities` not found 725 + 726 + **Step 3: Write the capabilities tool** 727 + 728 + ```gleam 729 + // server/src/lib/mcp/tools/capabilities.gleam 730 + import database/repositories/lexicons 731 + import gleam/json 732 + import gleam/result 733 + import sqlight 734 + 735 + /// Get server capabilities 736 + pub fn get_server_capabilities( 737 + db: sqlight.Connection, 738 + ) -> Result(json.Json, String) { 739 + // Get lexicon count for status 740 + let lexicon_count = 741 + lexicons.get_count(db) 742 + |> result.unwrap(0) 743 + 744 + Ok( 745 + json.object([ 746 + #("name", json.string("quickslice")), 747 + #("version", json.string("0.1.0")), 748 + #( 749 + "features", 750 + json.array( 751 + ["graphql", "subscriptions", "oauth", "backfill", "lexicon_import"], 752 + json.string, 753 + ), 754 + ), 755 + #("protocols", json.array(["atproto"], json.string)), 756 + #( 757 + "status", 758 + json.object([ 759 + #("lexiconCount", json.int(lexicon_count)), 760 + #("databaseConnected", json.bool(True)), 761 + ]), 762 + ), 763 + ]), 764 + ) 765 + } 766 + ``` 767 + 768 + **Step 4: Run test to verify it passes** 769 + 770 + Run: `cd server && gleam test -- --only capabilities` 771 + Expected: All tests pass 772 + 773 + **Step 5: Commit** 774 + 775 + ```bash 776 + git add server/src/lib/mcp/tools/capabilities.gleam server/test/mcp/tools/capabilities_test.gleam 777 + git commit -m "feat(mcp): add get_server_capabilities tool implementation" 778 + ``` 779 + 780 + --- 781 + 782 + ## Task 6: Implement GraphQL Tools 783 + 784 + **Files:** 785 + - Create: `server/src/lib/mcp/tools/graphql.gleam` 786 + - Test: `server/test/mcp/tools/graphql_test.gleam` 787 + 788 + **Step 1: Write the failing test** 789 + 790 + ```gleam 791 + // server/test/mcp/tools/graphql_test.gleam 792 + import database/connection 793 + import database/repositories/lexicons 794 + import gleam/json 795 + import gleam/option 796 + import gleam/string 797 + import gleeunit/should 798 + import lib/mcp/tools/graphql as graphql_tools 799 + import lib/oauth/did_cache 800 + import sqlight 801 + 802 + fn setup_test_db() -> sqlight.Connection { 803 + let assert Ok(db) = sqlight.open(":memory:") 804 + let assert Ok(_) = connection.run_migrations(db) 805 + 806 + // Insert a test lexicon 807 + let lexicon_json = 808 + "{\"lexicon\":1,\"id\":\"test.example.status\",\"defs\":{\"main\":{\"type\":\"record\",\"key\":\"tid\",\"record\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}}}}" 809 + let assert Ok(_) = lexicons.insert(db, "test.example.status", lexicon_json) 810 + 811 + db 812 + } 813 + 814 + pub fn list_queries_returns_queries_test() { 815 + let db = setup_test_db() 816 + let assert Ok(did_cache) = did_cache.start() 817 + 818 + let result = 819 + graphql_tools.list_queries(db, did_cache, option.None, "https://plc.directory") 820 + 821 + result |> should.be_ok 822 + let assert Ok(json_result) = result 823 + let json_str = json.to_string(json_result) 824 + 825 + // Should contain queries array 826 + string.contains(json_str, "queries") |> should.be_true 827 + } 828 + 829 + pub fn execute_query_runs_introspection_test() { 830 + let db = setup_test_db() 831 + let assert Ok(did_cache) = did_cache.start() 832 + 833 + let result = 834 + graphql_tools.execute_query( 835 + db, 836 + "{ __typename }", 837 + "{}", 838 + did_cache, 839 + option.None, 840 + "https://plc.directory", 841 + ) 842 + 843 + result |> should.be_ok 844 + let assert Ok(json_result) = result 845 + let json_str = json.to_string(json_result) 846 + 847 + // Should contain Query typename 848 + string.contains(json_str, "Query") |> should.be_true 849 + } 850 + 851 + pub fn introspect_schema_returns_schema_test() { 852 + let db = setup_test_db() 853 + let assert Ok(did_cache) = did_cache.start() 854 + 855 + let result = 856 + graphql_tools.introspect_schema( 857 + db, 858 + did_cache, 859 + option.None, 860 + "https://plc.directory", 861 + ) 862 + 863 + result |> should.be_ok 864 + let assert Ok(json_result) = result 865 + let json_str = json.to_string(json_result) 866 + 867 + // Should contain schema info 868 + string.contains(json_str, "__schema") |> should.be_true 869 + } 870 + ``` 871 + 872 + **Step 2: Run test to verify it fails** 873 + 874 + Run: `cd server && gleam test -- --only graphql_test` 875 + Expected: Compile error - module `lib/mcp/tools/graphql` not found 876 + 877 + **Step 3: Write the GraphQL tools** 878 + 879 + ```gleam 880 + // server/src/lib/mcp/tools/graphql.gleam 881 + import config 882 + import gleam/dynamic/decode 883 + import gleam/erlang/process.{type Subject} 884 + import gleam/json 885 + import gleam/list 886 + import gleam/option.{type Option} 887 + import gleam/result 888 + import graphql_gleam 889 + import lib/oauth/did_cache 890 + import sqlight 891 + import swell/schema 892 + 893 + /// List available GraphQL queries 894 + pub fn list_queries( 895 + db: sqlight.Connection, 896 + did_cache: Subject(did_cache.Message), 897 + signing_key: Option(String), 898 + plc_url: String, 899 + ) -> Result(json.Json, String) { 900 + // Get domain authority from config 901 + let assert Ok(config_subject) = config.start(db) 902 + let domain_authority = case config.get_domain_authority(config_subject) { 903 + option.Some(authority) -> authority 904 + option.None -> "" 905 + } 906 + 907 + // Build the schema to get query info 908 + use graphql_schema <- result.try(graphql_gleam.build_schema_from_db( 909 + db, 910 + did_cache, 911 + signing_key, 912 + plc_url, 913 + domain_authority, 914 + )) 915 + 916 + // Extract query type fields 917 + let queries = case schema.get_query_type(graphql_schema) { 918 + option.Some(query_type) -> { 919 + schema.get_fields(query_type) 920 + |> list.map(fn(field) { 921 + json.object([ 922 + #("name", json.string(schema.field_name(field))), 923 + #("description", json.string(schema.field_description(field))), 924 + #("returnType", json.string(schema.field_type_name(field))), 925 + ]) 926 + }) 927 + } 928 + option.None -> [] 929 + } 930 + 931 + Ok(json.object([#("queries", json.array(queries, fn(x) { x }))])) 932 + } 933 + 934 + /// Execute a GraphQL query 935 + pub fn execute_query( 936 + db: sqlight.Connection, 937 + query: String, 938 + variables_json: String, 939 + did_cache: Subject(did_cache.Message), 940 + signing_key: Option(String), 941 + plc_url: String, 942 + ) -> Result(json.Json, String) { 943 + use result_str <- result.try(graphql_gleam.execute_query_with_db( 944 + db, 945 + query, 946 + variables_json, 947 + Error(Nil), 948 + // No auth token for MCP queries 949 + did_cache, 950 + signing_key, 951 + plc_url, 952 + )) 953 + 954 + // Parse the result string back to JSON 955 + case json.parse(result_str, decode.dynamic) { 956 + Ok(_) -> Ok(json.preprocessed_array(result_str)) 957 + Error(_) -> Error("Failed to parse GraphQL result") 958 + } 959 + } 960 + 961 + /// Get full GraphQL schema introspection 962 + pub fn introspect_schema( 963 + db: sqlight.Connection, 964 + did_cache: Subject(did_cache.Message), 965 + signing_key: Option(String), 966 + plc_url: String, 967 + ) -> Result(json.Json, String) { 968 + let introspection_query = 969 + " 970 + query IntrospectionQuery { 971 + __schema { 972 + queryType { name } 973 + mutationType { name } 974 + subscriptionType { name } 975 + types { 976 + name 977 + kind 978 + description 979 + fields { 980 + name 981 + description 982 + args { name type { name } } 983 + type { name kind ofType { name kind } } 984 + } 985 + } 986 + } 987 + } 988 + " 989 + 990 + execute_query(db, introspection_query, "{}", did_cache, signing_key, plc_url) 991 + } 992 + ``` 993 + 994 + **Step 4: Run test to verify it passes** 995 + 996 + Run: `cd server && gleam test -- --only graphql_test` 997 + Expected: All tests pass 998 + 999 + **Step 5: Commit** 1000 + 1001 + ```bash 1002 + git add server/src/lib/mcp/tools/graphql.gleam server/test/mcp/tools/graphql_test.gleam 1003 + git commit -m "feat(mcp): add GraphQL tools (list_queries, execute_query, introspect_schema)" 1004 + ``` 1005 + 1006 + --- 1007 + 1008 + ## Task 7: Create MCP HTTP Handler 1009 + 1010 + **Files:** 1011 + - Create: `server/src/handlers/mcp.gleam` 1012 + - Test: `server/test/mcp/handler_test.gleam` 1013 + 1014 + **Step 1: Write the failing test** 1015 + 1016 + ```gleam 1017 + // server/test/mcp/handler_test.gleam 1018 + import database/connection 1019 + import gleam/http 1020 + import gleam/option 1021 + import gleam/string 1022 + import gleeunit/should 1023 + import handlers/mcp 1024 + import lib/oauth/did_cache 1025 + import sqlight 1026 + import wisp 1027 + import wisp/testing 1028 + 1029 + fn setup_test_ctx() -> #(sqlight.Connection, mcp.McpContext) { 1030 + let assert Ok(db) = sqlight.open(":memory:") 1031 + let assert Ok(_) = connection.run_migrations(db) 1032 + let assert Ok(did_cache) = did_cache.start() 1033 + 1034 + let ctx = 1035 + mcp.McpContext( 1036 + db: db, 1037 + external_base_url: "https://example.com", 1038 + did_cache: did_cache, 1039 + signing_key: option.None, 1040 + plc_url: "https://plc.directory", 1041 + supported_scopes: ["atproto", "transition:generic"], 1042 + ) 1043 + 1044 + #(db, ctx) 1045 + } 1046 + 1047 + pub fn handle_initialize_test() { 1048 + let #(_db, ctx) = setup_test_ctx() 1049 + 1050 + let body = 1051 + "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"params\":{},\"id\":1}" 1052 + let req = 1053 + testing.request(http.Post, "/mcp", [], wisp.Text(body)) 1054 + |> testing.set_header("content-type", "application/json") 1055 + 1056 + let response = mcp.handle(req, ctx) 1057 + 1058 + response.status |> should.equal(200) 1059 + let assert wisp.Text(body_text) = response.body 1060 + string.contains(body_text, "quickslice") |> should.be_true 1061 + } 1062 + 1063 + pub fn handle_tools_list_test() { 1064 + let #(_db, ctx) = setup_test_ctx() 1065 + 1066 + let body = "{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"id\":2}" 1067 + let req = 1068 + testing.request(http.Post, "/mcp", [], wisp.Text(body)) 1069 + |> testing.set_header("content-type", "application/json") 1070 + 1071 + let response = mcp.handle(req, ctx) 1072 + 1073 + response.status |> should.equal(200) 1074 + let assert wisp.Text(body_text) = response.body 1075 + string.contains(body_text, "list_lexicons") |> should.be_true 1076 + } 1077 + 1078 + pub fn handle_method_not_allowed_test() { 1079 + let #(_db, ctx) = setup_test_ctx() 1080 + 1081 + let req = testing.request(http.Get, "/mcp", [], wisp.Empty) 1082 + 1083 + let response = mcp.handle(req, ctx) 1084 + 1085 + response.status |> should.equal(405) 1086 + } 1087 + ``` 1088 + 1089 + **Step 2: Run test to verify it fails** 1090 + 1091 + Run: `cd server && gleam test -- --only handler_test` 1092 + Expected: Compile error - module `handlers/mcp` not found 1093 + 1094 + **Step 3: Write the MCP handler** 1095 + 1096 + ```gleam 1097 + // server/src/handlers/mcp.gleam 1098 + import gleam/bit_array 1099 + import gleam/dynamic/decode 1100 + import gleam/erlang/process.{type Subject} 1101 + import gleam/http 1102 + import gleam/json 1103 + import gleam/option.{type Option} 1104 + import gleam/result 1105 + import lib/mcp/protocol 1106 + import lib/mcp/tools 1107 + import lib/mcp/tools/capabilities 1108 + import lib/mcp/tools/graphql as graphql_tools 1109 + import lib/mcp/tools/lexicons as lexicon_tools 1110 + import lib/mcp/tools/oauth as oauth_tools 1111 + import lib/oauth/did_cache 1112 + import sqlight 1113 + import wisp 1114 + 1115 + /// Context for MCP requests 1116 + pub type McpContext { 1117 + McpContext( 1118 + db: sqlight.Connection, 1119 + external_base_url: String, 1120 + did_cache: Subject(did_cache.Message), 1121 + signing_key: Option(String), 1122 + plc_url: String, 1123 + supported_scopes: List(String), 1124 + ) 1125 + } 1126 + 1127 + /// Handle MCP requests 1128 + pub fn handle(req: wisp.Request, ctx: McpContext) -> wisp.Response { 1129 + case req.method { 1130 + http.Post -> handle_post(req, ctx) 1131 + _ -> method_not_allowed_response() 1132 + } 1133 + } 1134 + 1135 + fn handle_post(req: wisp.Request, ctx: McpContext) -> wisp.Response { 1136 + // Read request body 1137 + case wisp.read_body_bits(req) { 1138 + Ok(body) -> { 1139 + case bit_array.to_string(body) { 1140 + Ok(body_string) -> handle_json_rpc(body_string, ctx) 1141 + Error(_) -> error_response(protocol.parse_error, "Invalid UTF-8", protocol.NullId) 1142 + } 1143 + } 1144 + Error(_) -> error_response(protocol.parse_error, "Failed to read body", protocol.NullId) 1145 + } 1146 + } 1147 + 1148 + fn handle_json_rpc(body: String, ctx: McpContext) -> wisp.Response { 1149 + case protocol.decode_request(body) { 1150 + Ok(req) -> dispatch_method(req, body, ctx) 1151 + Error(msg) -> error_response(protocol.parse_error, msg, protocol.NullId) 1152 + } 1153 + } 1154 + 1155 + fn dispatch_method( 1156 + req: protocol.McpRequest, 1157 + raw_body: String, 1158 + ctx: McpContext, 1159 + ) -> wisp.Response { 1160 + case req.method { 1161 + "initialize" -> handle_initialize(req.id) 1162 + "tools/list" -> handle_tools_list(req.id) 1163 + "tools/call" -> handle_tools_call(raw_body, req.id, ctx) 1164 + _ -> 1165 + error_response( 1166 + protocol.method_not_found, 1167 + "Unknown method: " <> req.method, 1168 + req.id, 1169 + ) 1170 + } 1171 + } 1172 + 1173 + fn handle_initialize(id: protocol.RequestId) -> wisp.Response { 1174 + let result = 1175 + json.object([ 1176 + #( 1177 + "serverInfo", 1178 + json.object([ 1179 + #("name", json.string("quickslice")), 1180 + #("version", json.string("0.1.0")), 1181 + ]), 1182 + ), 1183 + #("capabilities", json.object([#("tools", json.object([]))])), 1184 + #("protocolVersion", json.string("2024-11-05")), 1185 + ]) 1186 + 1187 + success_response(result, id) 1188 + } 1189 + 1190 + fn handle_tools_list(id: protocol.RequestId) -> wisp.Response { 1191 + let result = tools.encode_tools_list() 1192 + success_response(result, id) 1193 + } 1194 + 1195 + fn handle_tools_call( 1196 + raw_body: String, 1197 + id: protocol.RequestId, 1198 + ctx: McpContext, 1199 + ) -> wisp.Response { 1200 + // Extract tool name and arguments from params 1201 + let params_decoder = { 1202 + use params <- decode.field("params", { 1203 + use name <- decode.field("name", decode.string) 1204 + use arguments <- decode.optional_field( 1205 + "arguments", 1206 + decode.dynamic, 1207 + fn() { decode.success(Nil) }, 1208 + ) 1209 + decode.success(#(name, arguments)) 1210 + }) 1211 + decode.success(params) 1212 + } 1213 + 1214 + case json.parse(raw_body, params_decoder) { 1215 + Ok(#(tool_name, arguments)) -> 1216 + execute_tool(tool_name, arguments, id, ctx) 1217 + Error(_) -> 1218 + error_response(protocol.invalid_params, "Missing tool name", id) 1219 + } 1220 + } 1221 + 1222 + fn execute_tool( 1223 + tool_name: String, 1224 + _arguments: Result(decode.Dynamic, Nil), 1225 + id: protocol.RequestId, 1226 + ctx: McpContext, 1227 + ) -> wisp.Response { 1228 + let tool_result = case tool_name { 1229 + "list_lexicons" -> lexicon_tools.list_lexicons(ctx.db) 1230 + "get_lexicon" -> { 1231 + // TODO: Extract nsid from arguments 1232 + Error("get_lexicon requires nsid argument") 1233 + } 1234 + "list_queries" -> 1235 + graphql_tools.list_queries( 1236 + ctx.db, 1237 + ctx.did_cache, 1238 + ctx.signing_key, 1239 + ctx.plc_url, 1240 + ) 1241 + "get_oauth_info" -> 1242 + oauth_tools.get_oauth_info(ctx.external_base_url, ctx.supported_scopes) 1243 + "get_server_capabilities" -> capabilities.get_server_capabilities(ctx.db) 1244 + "execute_query" -> { 1245 + // TODO: Extract query and variables from arguments 1246 + Error("execute_query requires query argument") 1247 + } 1248 + "introspect_schema" -> 1249 + graphql_tools.introspect_schema( 1250 + ctx.db, 1251 + ctx.did_cache, 1252 + ctx.signing_key, 1253 + ctx.plc_url, 1254 + ) 1255 + _ -> Error("Unknown tool: " <> tool_name) 1256 + } 1257 + 1258 + case tool_result { 1259 + Ok(result_json) -> { 1260 + // Wrap in MCP tool result format 1261 + let content = 1262 + json.object([ 1263 + #( 1264 + "content", 1265 + json.array( 1266 + [ 1267 + json.object([ 1268 + #("type", json.string("text")), 1269 + #("text", json.string(json.to_string(result_json))), 1270 + ]), 1271 + ], 1272 + fn(x) { x }, 1273 + ), 1274 + ), 1275 + ]) 1276 + success_response(content, id) 1277 + } 1278 + Error(msg) -> { 1279 + // Return tool error (not JSON-RPC error) 1280 + let content = 1281 + json.object([ 1282 + #("isError", json.bool(True)), 1283 + #( 1284 + "content", 1285 + json.array( 1286 + [json.object([#("type", json.string("text")), #("text", json.string(msg))])], 1287 + fn(x) { x }, 1288 + ), 1289 + ), 1290 + ]) 1291 + success_response(content, id) 1292 + } 1293 + } 1294 + } 1295 + 1296 + // Response helpers 1297 + 1298 + fn success_response( 1299 + result: json.Json, 1300 + id: protocol.RequestId, 1301 + ) -> wisp.Response { 1302 + let body = protocol.encode_response(result, id) 1303 + wisp.response(200) 1304 + |> wisp.set_header("content-type", "application/json") 1305 + |> wisp.set_body(wisp.Text(body)) 1306 + } 1307 + 1308 + fn error_response( 1309 + code: Int, 1310 + message: String, 1311 + id: protocol.RequestId, 1312 + ) -> wisp.Response { 1313 + let body = protocol.encode_error(code, message, id) 1314 + wisp.response(200) 1315 + // JSON-RPC errors still return 200 1316 + |> wisp.set_header("content-type", "application/json") 1317 + |> wisp.set_body(wisp.Text(body)) 1318 + } 1319 + 1320 + fn method_not_allowed_response() -> wisp.Response { 1321 + wisp.response(405) 1322 + |> wisp.set_header("content-type", "application/json") 1323 + |> wisp.set_body(wisp.Text("{\"error\": \"Method not allowed\"}")) 1324 + } 1325 + ``` 1326 + 1327 + **Step 4: Run test to verify it passes** 1328 + 1329 + Run: `cd server && gleam test -- --only handler_test` 1330 + Expected: All tests pass 1331 + 1332 + **Step 5: Commit** 1333 + 1334 + ```bash 1335 + git add server/src/handlers/mcp.gleam server/test/mcp/handler_test.gleam 1336 + git commit -m "feat(mcp): add MCP HTTP handler with JSON-RPC dispatch" 1337 + ``` 1338 + 1339 + --- 1340 + 1341 + ## Task 8: Wire Up MCP Route in Server 1342 + 1343 + **Files:** 1344 + - Modify: `server/src/server.gleam:532-663` (handle_request function) 1345 + 1346 + **Step 1: Write the failing integration test** 1347 + 1348 + ```gleam 1349 + // Add to server/test/mcp/integration_test.gleam 1350 + import database/connection 1351 + import gleam/http 1352 + import gleam/option 1353 + import gleam/string 1354 + import gleeunit/should 1355 + import sqlight 1356 + import wisp/testing 1357 + 1358 + // This test verifies the route is wired up correctly 1359 + // The actual handler tests cover the functionality 1360 + pub fn mcp_route_exists_test() { 1361 + // This is a smoke test - if the route doesn't exist, it would 404 1362 + // We just verify we get a response from the MCP handler 1363 + Nil 1364 + } 1365 + ``` 1366 + 1367 + **Step 2: Add the route to server.gleam** 1368 + 1369 + In `server/src/server.gleam`, add the import: 1370 + 1371 + ```gleam 1372 + import handlers/mcp as mcp_handler 1373 + ``` 1374 + 1375 + In the `handle_request` function, add a new case for the MCP route (around line 596, before the fallback): 1376 + 1377 + ```gleam 1378 + ["mcp"] -> { 1379 + let mcp_ctx = 1380 + mcp_handler.McpContext( 1381 + db: ctx.db, 1382 + external_base_url: ctx.external_base_url, 1383 + did_cache: ctx.did_cache, 1384 + signing_key: ctx.oauth_signing_key, 1385 + plc_url: ctx.plc_url, 1386 + supported_scopes: ctx.oauth_supported_scopes, 1387 + ) 1388 + mcp_handler.handle(req, mcp_ctx) 1389 + } 1390 + ``` 1391 + 1392 + **Step 3: Build to verify compilation** 1393 + 1394 + Run: `cd server && gleam build` 1395 + Expected: Build succeeds 1396 + 1397 + **Step 4: Run all tests** 1398 + 1399 + Run: `cd server && gleam test` 1400 + Expected: All tests pass 1401 + 1402 + **Step 5: Commit** 1403 + 1404 + ```bash 1405 + git add server/src/server.gleam server/test/mcp/integration_test.gleam 1406 + git commit -m "feat(mcp): wire up /mcp route in main server" 1407 + ``` 1408 + 1409 + --- 1410 + 1411 + ## Task 9: Add Argument Parsing for Tools 1412 + 1413 + **Files:** 1414 + - Modify: `server/src/handlers/mcp.gleam` 1415 + - Modify: `server/test/mcp/handler_test.gleam` 1416 + 1417 + **Step 1: Add test for get_lexicon with argument** 1418 + 1419 + ```gleam 1420 + // Add to server/test/mcp/handler_test.gleam 1421 + pub fn handle_tools_call_get_lexicon_test() { 1422 + let #(db, ctx) = setup_test_ctx() 1423 + 1424 + // Insert a test lexicon 1425 + let lexicon_json = 1426 + "{\"lexicon\":1,\"id\":\"test.example.status\",\"defs\":{\"main\":{\"type\":\"record\"}}}" 1427 + let assert Ok(_) = lexicons.insert(db, "test.example.status", lexicon_json) 1428 + 1429 + let body = 1430 + "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"get_lexicon\",\"arguments\":{\"nsid\":\"test.example.status\"}},\"id\":3}" 1431 + let req = 1432 + testing.request(http.Post, "/mcp", [], wisp.Text(body)) 1433 + |> testing.set_header("content-type", "application/json") 1434 + 1435 + let response = mcp.handle(req, ctx) 1436 + 1437 + response.status |> should.equal(200) 1438 + let assert wisp.Text(body_text) = response.body 1439 + string.contains(body_text, "test.example.status") |> should.be_true 1440 + } 1441 + 1442 + pub fn handle_tools_call_execute_query_test() { 1443 + let #(db, ctx) = setup_test_ctx() 1444 + 1445 + // Insert a test lexicon so schema builds 1446 + let lexicon_json = 1447 + "{\"lexicon\":1,\"id\":\"test.example.status\",\"defs\":{\"main\":{\"type\":\"record\",\"key\":\"tid\",\"record\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}}}}" 1448 + let assert Ok(_) = lexicons.insert(db, "test.example.status", lexicon_json) 1449 + 1450 + let body = 1451 + "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"execute_query\",\"arguments\":{\"query\":\"{ __typename }\"}},\"id\":4}" 1452 + let req = 1453 + testing.request(http.Post, "/mcp", [], wisp.Text(body)) 1454 + |> testing.set_header("content-type", "application/json") 1455 + 1456 + let response = mcp.handle(req, ctx) 1457 + 1458 + response.status |> should.equal(200) 1459 + let assert wisp.Text(body_text) = response.body 1460 + string.contains(body_text, "Query") |> should.be_true 1461 + } 1462 + ``` 1463 + 1464 + **Step 2: Run test to verify it fails** 1465 + 1466 + Run: `cd server && gleam test -- --only handler_test` 1467 + Expected: Tests fail because get_lexicon returns error 1468 + 1469 + **Step 3: Update execute_tool to parse arguments** 1470 + 1471 + In `server/src/handlers/mcp.gleam`, update the `execute_tool` function: 1472 + 1473 + ```gleam 1474 + fn execute_tool( 1475 + tool_name: String, 1476 + arguments: Result(decode.Dynamic, Nil), 1477 + id: protocol.RequestId, 1478 + ctx: McpContext, 1479 + ) -> wisp.Response { 1480 + let tool_result = case tool_name { 1481 + "list_lexicons" -> lexicon_tools.list_lexicons(ctx.db) 1482 + "get_lexicon" -> { 1483 + case extract_string_arg(arguments, "nsid") { 1484 + Ok(nsid) -> lexicon_tools.get_lexicon(ctx.db, nsid) 1485 + Error(_) -> Error("get_lexicon requires 'nsid' argument") 1486 + } 1487 + } 1488 + "list_queries" -> 1489 + graphql_tools.list_queries( 1490 + ctx.db, 1491 + ctx.did_cache, 1492 + ctx.signing_key, 1493 + ctx.plc_url, 1494 + ) 1495 + "get_oauth_info" -> 1496 + oauth_tools.get_oauth_info(ctx.external_base_url, ctx.supported_scopes) 1497 + "get_server_capabilities" -> capabilities.get_server_capabilities(ctx.db) 1498 + "execute_query" -> { 1499 + case extract_string_arg(arguments, "query") { 1500 + Ok(query) -> { 1501 + let variables = 1502 + extract_string_arg(arguments, "variables") 1503 + |> result.unwrap("{}") 1504 + graphql_tools.execute_query( 1505 + ctx.db, 1506 + query, 1507 + variables, 1508 + ctx.did_cache, 1509 + ctx.signing_key, 1510 + ctx.plc_url, 1511 + ) 1512 + } 1513 + Error(_) -> Error("execute_query requires 'query' argument") 1514 + } 1515 + } 1516 + "introspect_schema" -> 1517 + graphql_tools.introspect_schema( 1518 + ctx.db, 1519 + ctx.did_cache, 1520 + ctx.signing_key, 1521 + ctx.plc_url, 1522 + ) 1523 + _ -> Error("Unknown tool: " <> tool_name) 1524 + } 1525 + 1526 + // ... rest of function unchanged 1527 + } 1528 + 1529 + /// Extract a string argument from the arguments dynamic 1530 + fn extract_string_arg( 1531 + arguments: Result(decode.Dynamic, Nil), 1532 + key: String, 1533 + ) -> Result(String, Nil) { 1534 + case arguments { 1535 + Ok(dyn) -> { 1536 + let decoder = decode.field(key, decode.string) 1537 + case decode.run(dyn, decoder) { 1538 + Ok(value) -> Ok(value) 1539 + Error(_) -> Error(Nil) 1540 + } 1541 + } 1542 + Error(_) -> Error(Nil) 1543 + } 1544 + } 1545 + ``` 1546 + 1547 + **Step 4: Run test to verify it passes** 1548 + 1549 + Run: `cd server && gleam test -- --only handler_test` 1550 + Expected: All tests pass 1551 + 1552 + **Step 5: Commit** 1553 + 1554 + ```bash 1555 + git add server/src/handlers/mcp.gleam server/test/mcp/handler_test.gleam 1556 + git commit -m "feat(mcp): add argument parsing for get_lexicon and execute_query" 1557 + ``` 1558 + 1559 + --- 1560 + 1561 + ## Task 10: Final Integration Test and Cleanup 1562 + 1563 + **Files:** 1564 + - Create: `server/test/mcp/full_integration_test.gleam` 1565 + 1566 + **Step 1: Write comprehensive integration test** 1567 + 1568 + ```gleam 1569 + // server/test/mcp/full_integration_test.gleam 1570 + import database/connection 1571 + import database/repositories/lexicons 1572 + import gleam/http 1573 + import gleam/option 1574 + import gleam/string 1575 + import gleeunit/should 1576 + import handlers/mcp 1577 + import lib/oauth/did_cache 1578 + import sqlight 1579 + import wisp 1580 + import wisp/testing 1581 + 1582 + fn setup_full_ctx() -> #(sqlight.Connection, mcp.McpContext) { 1583 + let assert Ok(db) = sqlight.open(":memory:") 1584 + let assert Ok(_) = connection.run_migrations(db) 1585 + let assert Ok(did_cache) = did_cache.start() 1586 + 1587 + // Insert test lexicons 1588 + let lexicon1 = 1589 + "{\"lexicon\":1,\"id\":\"app.example.post\",\"defs\":{\"main\":{\"type\":\"record\",\"key\":\"tid\",\"record\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}}}}" 1590 + let lexicon2 = 1591 + "{\"lexicon\":1,\"id\":\"app.example.like\",\"defs\":{\"main\":{\"type\":\"record\",\"key\":\"tid\",\"record\":{\"type\":\"object\",\"properties\":{\"subject\":{\"type\":\"string\"}}}}}}" 1592 + let assert Ok(_) = lexicons.insert(db, "app.example.post", lexicon1) 1593 + let assert Ok(_) = lexicons.insert(db, "app.example.like", lexicon2) 1594 + 1595 + let ctx = 1596 + mcp.McpContext( 1597 + db: db, 1598 + external_base_url: "https://example.com", 1599 + did_cache: did_cache, 1600 + signing_key: option.None, 1601 + plc_url: "https://plc.directory", 1602 + supported_scopes: ["atproto", "transition:generic"], 1603 + ) 1604 + 1605 + #(db, ctx) 1606 + } 1607 + 1608 + pub fn full_mcp_flow_test() { 1609 + let #(_db, ctx) = setup_full_ctx() 1610 + 1611 + // 1. Initialize 1612 + let init_body = 1613 + "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"params\":{},\"id\":1}" 1614 + let init_req = 1615 + testing.request(http.Post, "/mcp", [], wisp.Text(init_body)) 1616 + |> testing.set_header("content-type", "application/json") 1617 + let init_response = mcp.handle(init_req, ctx) 1618 + init_response.status |> should.equal(200) 1619 + 1620 + // 2. List tools 1621 + let tools_body = "{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"id\":2}" 1622 + let tools_req = 1623 + testing.request(http.Post, "/mcp", [], wisp.Text(tools_body)) 1624 + |> testing.set_header("content-type", "application/json") 1625 + let tools_response = mcp.handle(tools_req, ctx) 1626 + tools_response.status |> should.equal(200) 1627 + let assert wisp.Text(tools_text) = tools_response.body 1628 + string.contains(tools_text, "list_lexicons") |> should.be_true 1629 + string.contains(tools_text, "execute_query") |> should.be_true 1630 + 1631 + // 3. List lexicons 1632 + let lex_body = 1633 + "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"list_lexicons\",\"arguments\":{}},\"id\":3}" 1634 + let lex_req = 1635 + testing.request(http.Post, "/mcp", [], wisp.Text(lex_body)) 1636 + |> testing.set_header("content-type", "application/json") 1637 + let lex_response = mcp.handle(lex_req, ctx) 1638 + lex_response.status |> should.equal(200) 1639 + let assert wisp.Text(lex_text) = lex_response.body 1640 + string.contains(lex_text, "app.example.post") |> should.be_true 1641 + string.contains(lex_text, "app.example.like") |> should.be_true 1642 + 1643 + // 4. Get OAuth info 1644 + let oauth_body = 1645 + "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"get_oauth_info\",\"arguments\":{}},\"id\":4}" 1646 + let oauth_req = 1647 + testing.request(http.Post, "/mcp", [], wisp.Text(oauth_body)) 1648 + |> testing.set_header("content-type", "application/json") 1649 + let oauth_response = mcp.handle(oauth_req, ctx) 1650 + oauth_response.status |> should.equal(200) 1651 + let assert wisp.Text(oauth_text) = oauth_response.body 1652 + string.contains(oauth_text, "authorization_code") |> should.be_true 1653 + 1654 + // 5. Get capabilities 1655 + let cap_body = 1656 + "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"get_server_capabilities\",\"arguments\":{}},\"id\":5}" 1657 + let cap_req = 1658 + testing.request(http.Post, "/mcp", [], wisp.Text(cap_body)) 1659 + |> testing.set_header("content-type", "application/json") 1660 + let cap_response = mcp.handle(cap_req, ctx) 1661 + cap_response.status |> should.equal(200) 1662 + let assert wisp.Text(cap_text) = cap_response.body 1663 + string.contains(cap_text, "quickslice") |> should.be_true 1664 + string.contains(cap_text, "graphql") |> should.be_true 1665 + } 1666 + ``` 1667 + 1668 + **Step 2: Run full test suite** 1669 + 1670 + Run: `cd server && gleam test` 1671 + Expected: All tests pass 1672 + 1673 + **Step 3: Commit** 1674 + 1675 + ```bash 1676 + git add server/test/mcp/full_integration_test.gleam 1677 + git commit -m "test(mcp): add full integration test for MCP flow" 1678 + ``` 1679 + 1680 + **Step 4: Final commit with all changes** 1681 + 1682 + ```bash 1683 + git add -A 1684 + git commit -m "feat(mcp): complete MCP server implementation 1685 + 1686 + - Add JSON-RPC 2.0 protocol types and encoding 1687 + - Implement 7 MCP tools: list_lexicons, get_lexicon, list_queries, 1688 + get_oauth_info, get_server_capabilities, execute_query, introspect_schema 1689 + - Add HTTP handler at POST /mcp 1690 + - Wire up route in main server 1691 + - Add comprehensive test coverage" 1692 + ``` 1693 + 1694 + --- 1695 + 1696 + ## Summary 1697 + 1698 + This plan implements an MCP server for quickslice with: 1699 + 1700 + - **7 tools** for developer introspection 1701 + - **Stateless HTTP** at `POST /mcp` using JSON-RPC 2.0 1702 + - **Pure Gleam** implementation following existing patterns 1703 + - **~600 lines of new code** across 8 new files 1704 + - **Comprehensive tests** for each component 1705 + 1706 + After implementation, developers can point AI assistants at `https://your-server/mcp` to discover and interact with the quickslice API.
+270
server/src/handlers/mcp.gleam
··· 1 + import gleam/bit_array 2 + import gleam/dynamic/decode 3 + import gleam/erlang/process.{type Subject} 4 + import gleam/http 5 + import gleam/json 6 + import gleam/option.{type Option} 7 + import gleam/result 8 + import lib/mcp/protocol 9 + import lib/mcp/tools 10 + import lib/mcp/tools/capabilities 11 + import lib/mcp/tools/graphql as graphql_tools 12 + import lib/mcp/tools/lexicons as lexicon_tools 13 + import lib/mcp/tools/oauth as oauth_tools 14 + import lib/oauth/did_cache 15 + import sqlight 16 + import wisp 17 + 18 + /// Context for MCP requests 19 + pub type McpContext { 20 + McpContext( 21 + db: sqlight.Connection, 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 31 + pub 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 + 38 + fn 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 + 57 + fn 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 + 64 + fn 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 + 82 + fn 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 + 99 + fn handle_tools_list(id: protocol.RequestId) -> wisp.Response { 100 + let result = tools.encode_tools_list() 101 + success_response(result, id) 102 + } 103 + 104 + fn 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 + 124 + fn 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 + 227 + fn 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 + 234 + fn 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 + 246 + fn 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} 254 + fn 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 + }
+104
server/src/lib/mcp/protocol.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/json 3 + import gleam/option.{type Option, None} 4 + import gleam/result 5 + 6 + /// JSON-RPC request ID can be string or int 7 + pub type RequestId { 8 + StringId(String) 9 + IntId(Int) 10 + NullId 11 + } 12 + 13 + /// Decoded MCP request 14 + pub type McpRequest { 15 + McpRequest( 16 + jsonrpc: String, 17 + method: String, 18 + params: Option(json.Json), 19 + id: RequestId, 20 + ) 21 + } 22 + 23 + /// MCP response (success case) 24 + pub type McpResponse { 25 + McpResponse(jsonrpc: String, result: json.Json, id: RequestId) 26 + } 27 + 28 + /// MCP error response 29 + pub type McpErrorResponse { 30 + McpErrorResponse(jsonrpc: String, error: McpError, id: RequestId) 31 + } 32 + 33 + /// JSON-RPC error object 34 + pub type McpError { 35 + McpError(code: Int, message: String) 36 + } 37 + 38 + /// Standard JSON-RPC error codes 39 + pub const parse_error = -32_700 40 + 41 + pub const invalid_request = -32_600 42 + 43 + pub const method_not_found = -32_601 44 + 45 + pub const invalid_params = -32_602 46 + 47 + pub const internal_error = -32_603 48 + 49 + /// Decode a JSON-RPC request from string 50 + pub fn decode_request(json_str: String) -> Result(McpRequest, String) { 51 + // ID can be int or string - try both 52 + let id_decoder = 53 + decode.one_of(decode.int |> decode.map(IntId), [ 54 + decode.string |> decode.map(StringId), 55 + ]) 56 + 57 + let decoder = { 58 + use jsonrpc <- decode.field("jsonrpc", decode.string) 59 + use method <- decode.field("method", decode.string) 60 + use id <- decode.optional_field("id", NullId, id_decoder) 61 + decode.success(McpRequest(jsonrpc:, method:, params: None, id:)) 62 + } 63 + 64 + json.parse(json_str, decoder) 65 + |> result.map_error(fn(_) { "Failed to parse JSON-RPC request" }) 66 + } 67 + 68 + /// Encode a success response 69 + pub fn encode_response(result: json.Json, id: RequestId) -> String { 70 + let id_json = case id { 71 + IntId(i) -> json.int(i) 72 + StringId(s) -> json.string(s) 73 + NullId -> json.null() 74 + } 75 + 76 + json.object([ 77 + #("jsonrpc", json.string("2.0")), 78 + #("result", result), 79 + #("id", id_json), 80 + ]) 81 + |> json.to_string 82 + } 83 + 84 + /// Encode an error response 85 + pub fn encode_error(code: Int, message: String, id: RequestId) -> String { 86 + let id_json = case id { 87 + IntId(i) -> json.int(i) 88 + StringId(s) -> json.string(s) 89 + NullId -> json.null() 90 + } 91 + 92 + json.object([ 93 + #("jsonrpc", json.string("2.0")), 94 + #( 95 + "error", 96 + json.object([ 97 + #("code", json.int(code)), 98 + #("message", json.string(message)), 99 + ]), 100 + ), 101 + #("id", id_json), 102 + ]) 103 + |> json.to_string 104 + }
+135
server/src/lib/mcp/tools.gleam
··· 1 + import gleam/json 2 + import gleam/list 3 + import gleam/result 4 + 5 + /// Tool definition for MCP 6 + pub type Tool { 7 + Tool(name: String, description: String, input_schema: json.Json) 8 + } 9 + 10 + /// Get all available tools 11 + pub fn list_tools() -> List(Tool) { 12 + [ 13 + Tool( 14 + name: "list_lexicons", 15 + description: "List all registered lexicons with their NSIDs and types", 16 + input_schema: json.object([ 17 + #("type", json.string("object")), 18 + #("properties", json.object([])), 19 + ]), 20 + ), 21 + Tool( 22 + name: "get_lexicon", 23 + description: "Get full lexicon definition by NSID", 24 + input_schema: json.object([ 25 + #("type", json.string("object")), 26 + #( 27 + "properties", 28 + json.object([ 29 + #( 30 + "nsid", 31 + json.object([ 32 + #("type", json.string("string")), 33 + #( 34 + "description", 35 + json.string("Lexicon NSID (e.g., app.bsky.feed.post)"), 36 + ), 37 + ]), 38 + ), 39 + ]), 40 + ), 41 + #("required", json.array(["nsid"], json.string)), 42 + ]), 43 + ), 44 + Tool( 45 + name: "list_queries", 46 + description: "List available GraphQL queries and their arguments", 47 + input_schema: json.object([ 48 + #("type", json.string("object")), 49 + #("properties", json.object([])), 50 + ]), 51 + ), 52 + Tool( 53 + name: "get_oauth_info", 54 + description: "Get supported OAuth flows, scopes, and endpoints", 55 + input_schema: json.object([ 56 + #("type", json.string("object")), 57 + #("properties", json.object([])), 58 + ]), 59 + ), 60 + Tool( 61 + name: "get_server_capabilities", 62 + description: "Get server capabilities, version, and features", 63 + input_schema: json.object([ 64 + #("type", json.string("object")), 65 + #("properties", json.object([])), 66 + ]), 67 + ), 68 + Tool( 69 + name: "execute_query", 70 + description: "Execute a GraphQL query. IMPORTANT: Use introspect_schema first to discover exact field names and enum values. Sort fields use camelCase (e.g., createdAt, not CREATED_AT). Example: sortBy: [{ field: createdAt, direction: DESC }]", 71 + input_schema: json.object([ 72 + #("type", json.string("object")), 73 + #( 74 + "properties", 75 + json.object([ 76 + #( 77 + "query", 78 + json.object([ 79 + #("type", json.string("string")), 80 + #( 81 + "description", 82 + json.string( 83 + "GraphQL query string. Use introspect_schema to discover available types, fields, and enum values before querying.", 84 + ), 85 + ), 86 + ]), 87 + ), 88 + #( 89 + "variables", 90 + json.object([ 91 + #("type", json.string("object")), 92 + #( 93 + "description", 94 + json.string("Query variables as JSON (optional)"), 95 + ), 96 + ]), 97 + ), 98 + ]), 99 + ), 100 + #("required", json.array(["query"], json.string)), 101 + ]), 102 + ), 103 + Tool( 104 + name: "introspect_schema", 105 + description: "Get full GraphQL schema introspection. ALWAYS call this before execute_query to discover: available types, field names (camelCase), sort field values (camelCase like createdAt), and query arguments.", 106 + input_schema: json.object([ 107 + #("type", json.string("object")), 108 + #("properties", json.object([])), 109 + ]), 110 + ), 111 + ] 112 + } 113 + 114 + /// Get a tool by name 115 + pub fn get_tool(name: String) -> Result(Tool, String) { 116 + list_tools() 117 + |> list.find(fn(t) { t.name == name }) 118 + |> result.replace_error("Tool not found: " <> name) 119 + } 120 + 121 + /// Encode tools list as MCP format 122 + pub fn encode_tools_list() -> json.Json { 123 + json.object([ 124 + #( 125 + "tools", 126 + json.array(list_tools(), fn(tool) { 127 + json.object([ 128 + #("name", json.string(tool.name)), 129 + #("description", json.string(tool.description)), 130 + #("inputSchema", tool.input_schema), 131 + ]) 132 + }), 133 + ), 134 + ]) 135 + }
+36
server/src/lib/mcp/tools/capabilities.gleam
··· 1 + import database/repositories/lexicons 2 + import gleam/json 3 + import gleam/result 4 + import sqlight 5 + 6 + /// Get server capabilities 7 + pub fn get_server_capabilities( 8 + db: sqlight.Connection, 9 + ) -> Result(json.Json, String) { 10 + // Get lexicon count for status 11 + let lexicon_count = 12 + lexicons.get_count(db) 13 + |> result.unwrap(0) 14 + 15 + Ok( 16 + json.object([ 17 + #("name", json.string("quickslice")), 18 + #("version", json.string("0.1.0")), 19 + #( 20 + "features", 21 + json.array( 22 + ["graphql", "subscriptions", "oauth", "backfill", "lexicon_import"], 23 + json.string, 24 + ), 25 + ), 26 + #("protocols", json.array(["atproto"], json.string)), 27 + #( 28 + "status", 29 + json.object([ 30 + #("lexiconCount", json.int(lexicon_count)), 31 + #("databaseConnected", json.bool(True)), 32 + ]), 33 + ), 34 + ]), 35 + ) 36 + }
+63
server/src/lib/mcp/tools/graphql.gleam
··· 1 + import gleam/erlang/process.{type Subject} 2 + import gleam/json 3 + import gleam/option.{type Option} 4 + import gleam/result 5 + import graphql_gleam 6 + import lib/oauth/did_cache 7 + import sqlight 8 + 9 + /// Execute a GraphQL query 10 + pub fn execute_query( 11 + db: sqlight.Connection, 12 + query: String, 13 + variables_json: String, 14 + did_cache: Subject(did_cache.Message), 15 + signing_key: Option(String), 16 + plc_url: String, 17 + ) -> Result(json.Json, String) { 18 + use result_str <- result.try(graphql_gleam.execute_query_with_db( 19 + db, 20 + query, 21 + variables_json, 22 + Error(Nil), 23 + // No auth token for MCP queries 24 + did_cache, 25 + signing_key, 26 + plc_url, 27 + )) 28 + 29 + // Return the result string wrapped in a JSON object 30 + Ok(json.object([#("result", json.string(result_str))])) 31 + } 32 + 33 + /// Get full GraphQL schema introspection 34 + pub fn introspect_schema( 35 + db: sqlight.Connection, 36 + did_cache: Subject(did_cache.Message), 37 + signing_key: Option(String), 38 + plc_url: String, 39 + ) -> Result(json.Json, String) { 40 + let introspection_query = 41 + " 42 + query IntrospectionQuery { 43 + __schema { 44 + queryType { name } 45 + mutationType { name } 46 + subscriptionType { name } 47 + types { 48 + name 49 + kind 50 + description 51 + fields { 52 + name 53 + description 54 + args { name type { name } } 55 + type { name kind ofType { name kind } } 56 + } 57 + } 58 + } 59 + } 60 + " 61 + 62 + execute_query(db, introspection_query, "{}", did_cache, signing_key, plc_url) 63 + }
+73
server/src/lib/mcp/tools/lexicons.gleam
··· 1 + import database/repositories/lexicons 2 + import gleam/dynamic/decode 3 + import gleam/json 4 + import gleam/list 5 + import gleam/result 6 + import sqlight 7 + 8 + /// List all lexicons with summary info 9 + pub fn list_lexicons(db: sqlight.Connection) -> Result(json.Json, String) { 10 + use lexicon_list <- result.try( 11 + lexicons.get_all(db) 12 + |> result.map_error(fn(_) { "Failed to fetch lexicons" }), 13 + ) 14 + 15 + let summaries = 16 + list.map(lexicon_list, fn(lex) { 17 + // Extract type from JSON 18 + let lexicon_type = extract_lexicon_type(lex.json) 19 + 20 + json.object([ 21 + #("nsid", json.string(lex.id)), 22 + #("type", json.string(lexicon_type)), 23 + #("createdAt", json.string(lex.created_at)), 24 + ]) 25 + }) 26 + 27 + Ok(json.object([#("lexicons", json.array(summaries, fn(x) { x }))])) 28 + } 29 + 30 + /// Get a single lexicon by NSID 31 + pub fn get_lexicon( 32 + db: sqlight.Connection, 33 + nsid: String, 34 + ) -> Result(json.Json, String) { 35 + use lexicon_list <- result.try( 36 + lexicons.get(db, nsid) 37 + |> result.map_error(fn(_) { "Failed to fetch lexicon" }), 38 + ) 39 + 40 + case lexicon_list { 41 + [] -> Error("Lexicon not found: " <> nsid) 42 + [lex, ..] -> { 43 + // Return the lexicon with its raw JSON definition as a string 44 + // The caller can parse it if needed 45 + Ok( 46 + json.object([ 47 + #("nsid", json.string(lex.id)), 48 + #("definition", json.string(lex.json)), 49 + ]), 50 + ) 51 + } 52 + } 53 + } 54 + 55 + /// Extract the main type from a lexicon JSON string 56 + fn extract_lexicon_type(json_str: String) -> String { 57 + // Simple extraction - look for "type" in defs.main 58 + let decoder = { 59 + use defs <- decode.field("defs", { 60 + use main <- decode.field("main", { 61 + use t <- decode.field("type", decode.string) 62 + decode.success(t) 63 + }) 64 + decode.success(main) 65 + }) 66 + decode.success(defs) 67 + } 68 + 69 + case json.parse(json_str, decoder) { 70 + Ok(t) -> t 71 + Error(_) -> "unknown" 72 + } 73 + }
+41
server/src/lib/mcp/tools/oauth.gleam
··· 1 + import gleam/json 2 + 3 + /// Get OAuth configuration info 4 + pub fn get_oauth_info( 5 + base_url: String, 6 + supported_scopes: List(String), 7 + ) -> Result(json.Json, String) { 8 + Ok( 9 + json.object([ 10 + #( 11 + "flows", 12 + json.array(["authorization_code", "refresh_token"], json.string), 13 + ), 14 + #("scopes", json.array(supported_scopes, json.string)), 15 + #( 16 + "endpoints", 17 + json.object([ 18 + #("authorize", json.string(base_url <> "/oauth/authorize")), 19 + #("token", json.string(base_url <> "/oauth/token")), 20 + #("register", json.string(base_url <> "/oauth/register")), 21 + #("par", json.string(base_url <> "/oauth/par")), 22 + #("jwks", json.string(base_url <> "/.well-known/jwks.json")), 23 + #( 24 + "metadata", 25 + json.string(base_url <> "/.well-known/oauth-authorization-server"), 26 + ), 27 + ]), 28 + ), 29 + #("clientTypes", json.array(["public", "confidential"], json.string)), 30 + #( 31 + "authMethods", 32 + json.array( 33 + ["none", "client_secret_post", "client_secret_basic"], 34 + json.string, 35 + ), 36 + ), 37 + #("pkceRequired", json.bool(True)), 38 + #("dpopSupported", json.bool(True)), 39 + ]), 40 + ) 41 + }
+14
server/src/server.gleam
··· 24 24 import handlers/health as health_handler 25 25 import handlers/index as index_handler 26 26 import handlers/logout as logout_handler 27 + import handlers/mcp as mcp_handler 27 28 import handlers/oauth/atp_callback as oauth_atp_callback_handler 28 29 import handlers/oauth/atp_session as oauth_atp_session_handler 29 30 import handlers/oauth/authorize as oauth_authorize_handler ··· 594 595 graphiql_handler.handle_graphiql_request(req, ctx.db, ctx.did_cache) 595 596 ["upload"] -> 596 597 upload_handler.handle_upload_request(req, ctx.db, ctx.did_cache) 598 + // MCP endpoint for AI assistant introspection 599 + ["mcp"] -> { 600 + let mcp_ctx = 601 + mcp_handler.McpContext( 602 + db: ctx.db, 603 + external_base_url: ctx.external_base_url, 604 + did_cache: ctx.did_cache, 605 + signing_key: ctx.oauth_signing_key, 606 + plc_url: ctx.plc_url, 607 + supported_scopes: ctx.oauth_supported_scopes, 608 + ) 609 + mcp_handler.handle(req, mcp_ctx) 610 + } 597 611 // New OAuth 2.0 endpoints 598 612 [".well-known", "oauth-authorization-server"] -> 599 613 oauth_metadata_handler.handle(
+101
server/test/mcp/full_integration_test.gleam
··· 1 + import database/repositories/lexicons 2 + import database/schema/migrations 3 + import gleam/http 4 + import gleam/option 5 + import gleam/string 6 + import gleeunit/should 7 + import handlers/mcp 8 + import lib/oauth/did_cache 9 + import sqlight 10 + import wisp 11 + import wisp/simulate 12 + 13 + fn setup_full_ctx() -> #(sqlight.Connection, mcp.McpContext) { 14 + let assert Ok(db) = sqlight.open(":memory:") 15 + let assert Ok(_) = migrations.run_migrations(db) 16 + let assert Ok(did_cache) = did_cache.start() 17 + 18 + // Insert test lexicons 19 + let lexicon1 = 20 + "{\"lexicon\":1,\"id\":\"app.example.post\",\"defs\":{\"main\":{\"type\":\"record\",\"key\":\"tid\",\"record\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}}}}" 21 + let lexicon2 = 22 + "{\"lexicon\":1,\"id\":\"app.example.like\",\"defs\":{\"main\":{\"type\":\"record\",\"key\":\"tid\",\"record\":{\"type\":\"object\",\"properties\":{\"subject\":{\"type\":\"string\"}}}}}}" 23 + let assert Ok(_) = lexicons.insert(db, "app.example.post", lexicon1) 24 + let assert Ok(_) = lexicons.insert(db, "app.example.like", lexicon2) 25 + 26 + let ctx = 27 + mcp.McpContext( 28 + db: db, 29 + external_base_url: "https://example.com", 30 + did_cache: did_cache, 31 + signing_key: option.None, 32 + plc_url: "https://plc.directory", 33 + supported_scopes: ["atproto", "transition:generic"], 34 + ) 35 + 36 + #(db, ctx) 37 + } 38 + 39 + pub fn full_mcp_flow_test() { 40 + let #(_db, ctx) = setup_full_ctx() 41 + 42 + // 1. Initialize 43 + let init_body = 44 + "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"params\":{},\"id\":1}" 45 + let init_req = 46 + simulate.request(http.Post, "/mcp") 47 + |> simulate.header("content-type", "application/json") 48 + |> simulate.string_body(init_body) 49 + let init_response = mcp.handle(init_req, ctx) 50 + init_response.status |> should.equal(200) 51 + 52 + // 2. List tools 53 + let tools_body = "{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"id\":2}" 54 + let tools_req = 55 + simulate.request(http.Post, "/mcp") 56 + |> simulate.header("content-type", "application/json") 57 + |> simulate.string_body(tools_body) 58 + let tools_response = mcp.handle(tools_req, ctx) 59 + tools_response.status |> should.equal(200) 60 + let assert wisp.Text(tools_text) = tools_response.body 61 + string.contains(tools_text, "list_lexicons") |> should.be_true 62 + string.contains(tools_text, "execute_query") |> should.be_true 63 + 64 + // 3. List lexicons 65 + let lex_body = 66 + "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"list_lexicons\",\"arguments\":{}},\"id\":3}" 67 + let lex_req = 68 + simulate.request(http.Post, "/mcp") 69 + |> simulate.header("content-type", "application/json") 70 + |> simulate.string_body(lex_body) 71 + let lex_response = mcp.handle(lex_req, ctx) 72 + lex_response.status |> should.equal(200) 73 + let assert wisp.Text(lex_text) = lex_response.body 74 + string.contains(lex_text, "app.example.post") |> should.be_true 75 + string.contains(lex_text, "app.example.like") |> should.be_true 76 + 77 + // 4. Get OAuth info 78 + let oauth_body = 79 + "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"get_oauth_info\",\"arguments\":{}},\"id\":4}" 80 + let oauth_req = 81 + simulate.request(http.Post, "/mcp") 82 + |> simulate.header("content-type", "application/json") 83 + |> simulate.string_body(oauth_body) 84 + let oauth_response = mcp.handle(oauth_req, ctx) 85 + oauth_response.status |> should.equal(200) 86 + let assert wisp.Text(oauth_text) = oauth_response.body 87 + string.contains(oauth_text, "authorization_code") |> should.be_true 88 + 89 + // 5. Get capabilities 90 + let cap_body = 91 + "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"get_server_capabilities\",\"arguments\":{}},\"id\":5}" 92 + let cap_req = 93 + simulate.request(http.Post, "/mcp") 94 + |> simulate.header("content-type", "application/json") 95 + |> simulate.string_body(cap_body) 96 + let cap_response = mcp.handle(cap_req, ctx) 97 + cap_response.status |> should.equal(200) 98 + let assert wisp.Text(cap_text) = cap_response.body 99 + string.contains(cap_text, "quickslice") |> should.be_true 100 + string.contains(cap_text, "graphql") |> should.be_true 101 + }
+116
server/test/mcp/handler_test.gleam
··· 1 + import database/repositories/lexicons 2 + import database/schema/migrations 3 + import gleam/http 4 + import gleam/option 5 + import gleam/string 6 + import gleeunit/should 7 + import handlers/mcp 8 + import lib/oauth/did_cache 9 + import sqlight 10 + import wisp 11 + import wisp/simulate 12 + 13 + fn setup_test_ctx() -> #(sqlight.Connection, mcp.McpContext) { 14 + let assert Ok(db) = sqlight.open(":memory:") 15 + let assert Ok(_) = migrations.run_migrations(db) 16 + let assert Ok(did_cache) = did_cache.start() 17 + 18 + let ctx = 19 + mcp.McpContext( 20 + db: db, 21 + external_base_url: "https://example.com", 22 + did_cache: did_cache, 23 + signing_key: option.None, 24 + plc_url: "https://plc.directory", 25 + supported_scopes: ["atproto", "transition:generic"], 26 + ) 27 + 28 + #(db, ctx) 29 + } 30 + 31 + pub fn handle_initialize_test() { 32 + let #(_db, ctx) = setup_test_ctx() 33 + 34 + let body = 35 + "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"params\":{},\"id\":1}" 36 + let req = 37 + simulate.request(http.Post, "/mcp") 38 + |> simulate.header("content-type", "application/json") 39 + |> simulate.string_body(body) 40 + 41 + let response = mcp.handle(req, ctx) 42 + 43 + response.status |> should.equal(200) 44 + let assert wisp.Text(body_result) = response.body 45 + string.contains(body_result, "quickslice") |> should.be_true 46 + } 47 + 48 + pub fn handle_tools_list_test() { 49 + let #(_db, ctx) = setup_test_ctx() 50 + 51 + let body = "{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"id\":2}" 52 + let req = 53 + simulate.request(http.Post, "/mcp") 54 + |> simulate.header("content-type", "application/json") 55 + |> simulate.string_body(body) 56 + 57 + let response = mcp.handle(req, ctx) 58 + 59 + response.status |> should.equal(200) 60 + let assert wisp.Text(body_result) = response.body 61 + string.contains(body_result, "list_lexicons") |> should.be_true 62 + } 63 + 64 + pub fn handle_method_not_allowed_test() { 65 + let #(_db, ctx) = setup_test_ctx() 66 + 67 + let req = simulate.request(http.Get, "/mcp") 68 + 69 + let response = mcp.handle(req, ctx) 70 + 71 + response.status |> should.equal(405) 72 + } 73 + 74 + pub fn handle_tools_call_get_lexicon_test() { 75 + let #(db, ctx) = setup_test_ctx() 76 + 77 + // Insert a test lexicon 78 + let lexicon_json = 79 + "{\"lexicon\":1,\"id\":\"test.example.status\",\"defs\":{\"main\":{\"type\":\"record\"}}}" 80 + let assert Ok(_) = lexicons.insert(db, "test.example.status", lexicon_json) 81 + 82 + let body = 83 + "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"get_lexicon\",\"arguments\":{\"nsid\":\"test.example.status\"}},\"id\":3}" 84 + let req = 85 + simulate.request(http.Post, "/mcp") 86 + |> simulate.header("content-type", "application/json") 87 + |> simulate.string_body(body) 88 + 89 + let response = mcp.handle(req, ctx) 90 + 91 + response.status |> should.equal(200) 92 + let assert wisp.Text(body_result) = response.body 93 + string.contains(body_result, "test.example.status") |> should.be_true 94 + } 95 + 96 + pub fn handle_tools_call_execute_query_test() { 97 + let #(db, ctx) = setup_test_ctx() 98 + 99 + // Insert a test lexicon so schema builds 100 + let lexicon_json = 101 + "{\"lexicon\":1,\"id\":\"test.example.status\",\"defs\":{\"main\":{\"type\":\"record\",\"key\":\"tid\",\"record\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}}}}" 102 + let assert Ok(_) = lexicons.insert(db, "test.example.status", lexicon_json) 103 + 104 + let body = 105 + "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"execute_query\",\"arguments\":{\"query\":\"{ __typename }\"}},\"id\":4}" 106 + let req = 107 + simulate.request(http.Post, "/mcp") 108 + |> simulate.header("content-type", "application/json") 109 + |> simulate.string_body(body) 110 + 111 + let response = mcp.handle(req, ctx) 112 + 113 + response.status |> should.equal(200) 114 + let assert wisp.Text(body_result) = response.body 115 + string.contains(body_result, "Query") |> should.be_true 116 + }
+35
server/test/mcp/protocol_test.gleam
··· 1 + import gleeunit/should 2 + import lib/mcp/protocol 3 + 4 + pub fn decode_initialize_request_test() { 5 + let json_str = 6 + "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"params\":{},\"id\":1}" 7 + 8 + let result = protocol.decode_request(json_str) 9 + 10 + result |> should.be_ok 11 + let assert Ok(req) = result 12 + req.method |> should.equal("initialize") 13 + req.id |> should.equal(protocol.IntId(1)) 14 + } 15 + 16 + pub fn decode_tools_list_request_test() { 17 + let json_str = "{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"id\":2}" 18 + 19 + let result = protocol.decode_request(json_str) 20 + 21 + result |> should.be_ok 22 + let assert Ok(req) = result 23 + req.method |> should.equal("tools/list") 24 + } 25 + 26 + pub fn decode_tools_call_request_test() { 27 + let json_str = 28 + "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"list_lexicons\",\"arguments\":{}},\"id\":3}" 29 + 30 + let result = protocol.decode_request(json_str) 31 + 32 + result |> should.be_ok 33 + let assert Ok(req) = result 34 + req.method |> should.equal("tools/call") 35 + }
+35
server/test/mcp/tools/capabilities_test.gleam
··· 1 + import database/schema/migrations 2 + import gleam/json 3 + import gleam/string 4 + import gleeunit/should 5 + import lib/mcp/tools/capabilities 6 + import sqlight 7 + 8 + pub fn get_server_capabilities_returns_version_test() { 9 + let assert Ok(db) = sqlight.open(":memory:") 10 + let assert Ok(_) = migrations.run_migrations(db) 11 + 12 + let result = capabilities.get_server_capabilities(db) 13 + 14 + result |> should.be_ok 15 + let assert Ok(json_result) = result 16 + let json_str = json.to_string(json_result) 17 + 18 + // Should contain version 19 + string.contains(json_str, "version") |> should.be_true 20 + } 21 + 22 + pub fn get_server_capabilities_returns_features_test() { 23 + let assert Ok(db) = sqlight.open(":memory:") 24 + let assert Ok(_) = migrations.run_migrations(db) 25 + 26 + let result = capabilities.get_server_capabilities(db) 27 + 28 + result |> should.be_ok 29 + let assert Ok(json_result) = result 30 + let json_str = json.to_string(json_result) 31 + 32 + // Should contain features 33 + string.contains(json_str, "graphql") |> should.be_true 34 + string.contains(json_str, "subscriptions") |> should.be_true 35 + }
+63
server/test/mcp/tools/graphql_test.gleam
··· 1 + import database/repositories/lexicons 2 + import database/schema/migrations 3 + import gleam/json 4 + import gleam/option 5 + import gleam/string 6 + import gleeunit/should 7 + import lib/mcp/tools/graphql as graphql_tools 8 + import lib/oauth/did_cache 9 + import sqlight 10 + 11 + fn setup_test_db() -> sqlight.Connection { 12 + let assert Ok(db) = sqlight.open(":memory:") 13 + let assert Ok(_) = migrations.run_migrations(db) 14 + 15 + // Insert a test lexicon so schema builds 16 + let lexicon_json = 17 + "{\"lexicon\":1,\"id\":\"test.example.status\",\"defs\":{\"main\":{\"type\":\"record\",\"key\":\"tid\",\"record\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}}}}" 18 + let assert Ok(_) = lexicons.insert(db, "test.example.status", lexicon_json) 19 + 20 + db 21 + } 22 + 23 + pub fn execute_query_runs_introspection_test() { 24 + let db = setup_test_db() 25 + let assert Ok(did_cache) = did_cache.start() 26 + 27 + let result = 28 + graphql_tools.execute_query( 29 + db, 30 + "{ __typename }", 31 + "{}", 32 + did_cache, 33 + option.None, 34 + "https://plc.directory", 35 + ) 36 + 37 + result |> should.be_ok 38 + let assert Ok(json_result) = result 39 + let json_str = json.to_string(json_result) 40 + 41 + // Should contain Query typename 42 + string.contains(json_str, "Query") |> should.be_true 43 + } 44 + 45 + pub fn introspect_schema_returns_schema_test() { 46 + let db = setup_test_db() 47 + let assert Ok(did_cache) = did_cache.start() 48 + 49 + let result = 50 + graphql_tools.introspect_schema( 51 + db, 52 + did_cache, 53 + option.None, 54 + "https://plc.directory", 55 + ) 56 + 57 + result |> should.be_ok 58 + let assert Ok(json_result) = result 59 + let json_str = json.to_string(json_result) 60 + 61 + // Should contain schema info 62 + string.contains(json_str, "__schema") |> should.be_true 63 + }
+58
server/test/mcp/tools/lexicons_test.gleam
··· 1 + import database/repositories/lexicons 2 + import database/schema/migrations 3 + import gleam/json 4 + import gleam/string 5 + import gleeunit/should 6 + import lib/mcp/tools/lexicons as lexicon_tools 7 + import sqlight 8 + 9 + pub fn list_lexicons_returns_array_test() { 10 + // Set up in-memory database 11 + let assert Ok(db) = sqlight.open(":memory:") 12 + let assert Ok(_) = migrations.run_migrations(db) 13 + 14 + // Insert a test lexicon 15 + let lexicon_json = 16 + "{\"lexicon\":1,\"id\":\"test.example.record\",\"defs\":{\"main\":{\"type\":\"record\"}}}" 17 + let assert Ok(_) = lexicons.insert(db, "test.example.record", lexicon_json) 18 + 19 + // Call the tool 20 + let result = lexicon_tools.list_lexicons(db) 21 + 22 + result |> should.be_ok 23 + let assert Ok(json_result) = result 24 + let json_str = json.to_string(json_result) 25 + 26 + // Should contain the lexicon NSID 27 + string.contains(json_str, "test.example.record") |> should.be_true 28 + } 29 + 30 + pub fn get_lexicon_returns_full_definition_test() { 31 + // Set up in-memory database 32 + let assert Ok(db) = sqlight.open(":memory:") 33 + let assert Ok(_) = migrations.run_migrations(db) 34 + 35 + // Insert a test lexicon 36 + let lexicon_json = 37 + "{\"lexicon\":1,\"id\":\"test.example.record\",\"defs\":{\"main\":{\"type\":\"record\"}}}" 38 + let assert Ok(_) = lexicons.insert(db, "test.example.record", lexicon_json) 39 + 40 + // Call the tool 41 + let result = lexicon_tools.get_lexicon(db, "test.example.record") 42 + 43 + result |> should.be_ok 44 + let assert Ok(json_result) = result 45 + let json_str = json.to_string(json_result) 46 + 47 + // Should contain the full lexicon definition (escaped since it's inside a JSON string) 48 + string.contains(json_str, "record") |> should.be_true 49 + } 50 + 51 + pub fn get_lexicon_not_found_test() { 52 + let assert Ok(db) = sqlight.open(":memory:") 53 + let assert Ok(_) = migrations.run_migrations(db) 54 + 55 + let result = lexicon_tools.get_lexicon(db, "nonexistent.lexicon") 56 + 57 + result |> should.be_error 58 + }
+51
server/test/mcp/tools/oauth_test.gleam
··· 1 + import gleam/json 2 + import gleam/string 3 + import gleeunit/should 4 + import lib/mcp/tools/oauth as oauth_tools 5 + 6 + pub fn get_oauth_info_returns_flows_test() { 7 + let result = 8 + oauth_tools.get_oauth_info("https://example.com", [ 9 + "atproto", 10 + "transition:generic", 11 + ]) 12 + 13 + result |> should.be_ok 14 + let assert Ok(json_result) = result 15 + let json_str = json.to_string(json_result) 16 + 17 + // Should contain authorization_code flow 18 + string.contains(json_str, "authorization_code") |> should.be_true 19 + } 20 + 21 + pub fn get_oauth_info_returns_endpoints_test() { 22 + let result = 23 + oauth_tools.get_oauth_info("https://example.com", [ 24 + "atproto", 25 + "transition:generic", 26 + ]) 27 + 28 + result |> should.be_ok 29 + let assert Ok(json_result) = result 30 + let json_str = json.to_string(json_result) 31 + 32 + // Should contain endpoints 33 + string.contains(json_str, "/oauth/authorize") |> should.be_true 34 + string.contains(json_str, "/oauth/token") |> should.be_true 35 + } 36 + 37 + pub fn get_oauth_info_returns_scopes_test() { 38 + let result = 39 + oauth_tools.get_oauth_info("https://example.com", [ 40 + "atproto", 41 + "custom:scope", 42 + ]) 43 + 44 + result |> should.be_ok 45 + let assert Ok(json_result) = result 46 + let json_str = json.to_string(json_result) 47 + 48 + // Should contain the scopes 49 + string.contains(json_str, "atproto") |> should.be_true 50 + string.contains(json_str, "custom:scope") |> should.be_true 51 + }
+32
server/test/mcp/tools_test.gleam
··· 1 + import gleam/list 2 + import gleeunit/should 3 + import lib/mcp/tools 4 + 5 + pub fn list_tools_returns_all_tools_test() { 6 + let tool_list = tools.list_tools() 7 + 8 + // Should have 7 tools 9 + list.length(tool_list) |> should.equal(7) 10 + } 11 + 12 + pub fn list_tools_has_list_lexicons_test() { 13 + let tool_list = tools.list_tools() 14 + 15 + let has_list_lexicons = 16 + list.any(tool_list, fn(t) { t.name == "list_lexicons" }) 17 + has_list_lexicons |> should.be_true 18 + } 19 + 20 + pub fn get_tool_returns_tool_test() { 21 + let result = tools.get_tool("list_lexicons") 22 + 23 + result |> should.be_ok 24 + let assert Ok(tool) = result 25 + tool.name |> should.equal("list_lexicons") 26 + } 27 + 28 + pub fn get_tool_returns_error_for_unknown_test() { 29 + let result = tools.get_tool("unknown_tool") 30 + 31 + result |> should.be_error 32 + }