Pudu programming language
Menu
Package

@chrismichaelps / pudu-lang-mcp

Model Context Protocol server for Pudu language documentation and compiler tools

0.1.1Apache-2.01

InstallClose

DispatchTest.pudu

Pudu199 lines17.5 KB

GitHub ↗
1/** @Test.App.DispatchTest.Suite — the protocol core end to end, scripted toolchain */2module PuduLangMcp.App.DispatchTest34import Std.Env as Env5import Std.Fs as Fs6import Std.Io as Io7import Std.Json as Json8import Std.List as List9import Std.Option as Option10import Std.Result as Result11import Std.Test as Test12import PuduLangMcp.App.Context as Context13import PuduLangMcp.App.Dispatch as Dispatch14import PuduLangMcp.Constants.Protocol as Protocol15import PuduLangMcp.Constants.Server as Server16import PuduLangMcp.Domain.Protocol.Negotiation as Negotiation17import PuduLangMcp.Generated.Docs as Docs18import PuduLangMcp.Services.Process.Bounded as Bounded19import PuduLangMcp.Services.Toolchain as Toolchain20import PuduLangMcp.Services.Workspace as Workspace21import PuduLangMcp.Utils.JsonAccess as Access2223/// Compiler answers for the index: one exported module with one declaration.24fn answer(arguments: Array[Str], input: Str, directory: Str) -> Bounded.Finished {25  let command = Option.unwrapOr(List.first(&arguments), "")26  if command == "api" {27    return Toolchain.finished(0, Json.encode(&Json.object(&[("exports", Json.list(&[Json.object(&[("module", Json.Text("Std.Io")), ("name", Json.Text("read"))])]))])))28  }29  if command == "doc" {30    return Toolchain.finished(0, Json.encode(&Json.object(&[("entries", Json.list(&[Json.object(&[31                      ("name", Json.Text("read")), ("kind", Json.Text("fn")), ("module", Json.Text("Std.Io")),32                      ("signature", Json.Text("Str -> Result[Str, Str]")), ("doc", Json.list(&[Json.Text("A file's text.")]))33                    ])]))])))34  }35  Toolchain.finished(0, "ok")36}3738/// Modern request parameters with the required metadata.39fn modern(fields: &Array[(Str, Json.Json)]) -> Json.Json {40  Json.object(&fields.push((Protocol.META_KEY, Json.object(&[41            (Protocol.META_PROTOCOL_VERSION, Json.Text(Protocol.MODERN_VERSION)),42            (Protocol.META_CLIENT_CAPABILITIES, Json.object(&[]))43          ]))))44}4546/// One request line.47fn request(id: Int, method: Str, params: Json.Json) -> Str {48  Json.encode(&Json.object(&[("jsonrpc", Json.Text("2.0")), ("id", Json.Number(id)), ("method", Json.Text(method)), ("params", params)]))49}5051/// The replies to one line, decoded, and the next state.52fn send(context: &Context.Context, state: &Dispatch.State, line: Str) -> (Dispatch.State, Array[Json.Json]) {53  let answered = Dispatch.respond(context, state, line)54  (answered[0], answered[1].map(|reply: Str| Result.unwrapOr(Json.decode(reply), Json.Null)))55}5657/// The only reply to a line in a fresh state.58fn only(context: &Context.Context, line: Str) -> Json.Json {59  let replies = send(context, &Dispatch.initial(), line)[1]60  if replies.length() == 1 { replies[0] } else { Json.Null }61}6263/// The error code of a reply, or 0.64fn errorCode(reply: &Json.Json) -> Int {65  Option.unwrapOr(Access.pathInt(reply, &["error", "code"]), 0)66}6768/// Runs the suite.69fn main() -> Int {70  let library = Result.unwrapOr(Fs.temporaryDirectoryIn(Env.temporaryDirectory(), "pudu-mcp-lib-"), "/nonexistent")71  let _made = Io.makeDirectory(library + "/Std")72  let _written = Io.write(library + "/Std/Io.pudu", "module Std.Io\n")73  var extra = 074  while extra < 101 {75    let _module = Io.write(library + "/Std/M" + show(extra) + ".pudu", "module Std.M" + show(extra) + "\n")76    extra = extra + 177  }78  let context = Context.create(Toolchain.scripted("0.1.1", Some(library), answer), Workspace.at(library), Docs.CHAPTERS, "test")79  let discover = only(&context, request(1, "server/discover", modern(&[])))80  let unopened = only(&context, request(2, "tools/list", Json.object(&[])))81  let opened = send(&context, &Dispatch.initial(), request(3, "initialize", Json.object(&[("protocolVersion", Json.Text("2025-06-18")), ("capabilities", Json.object(&[]))])))82  let legacy = opened[0]83  let legacyList = send(&context, &legacy, request(4, "tools/list", Json.object(&[])))[1]84  let legacyPing = send(&context, &legacy, request(5, "ping", Json.object(&[])))[1]85  let reopened = send(&context, &legacy, request(6, "initialize", Json.object(&[("protocolVersion", Json.Text("2024-11-05"))])))[0]86  let modernInitialize = only(&context, request(7, "initialize", modern(&[])))87  let modernPing = only(&context, request(8, "ping", modern(&[])))88  let unknownMethod = only(&context, request(9, "tools/destroy", modern(&[])))89  let unsupported = only(&context, Json.encode(&Json.object(&[("jsonrpc", Json.Text("2.0")), ("id", Json.Number(10)), ("method", Json.Text("tools/list")), ("params", Json.object(&[(Protocol.META_KEY, Json.object(&[(Protocol.META_PROTOCOL_VERSION, Json.Text("1900-01-01")), (Protocol.META_CLIENT_CAPABILITIES, Json.object(&[]))]))]))])))90  let notification = send(&context, &Dispatch.initial(), Json.encode(&Json.object(&[("jsonrpc", Json.Text("2.0")), ("method", Json.Text("notifications/cancelled")), ("params", Json.object(&[("requestId", Json.Number(1))]))])))[1]91  let clientResponse = send(&context, &Dispatch.initial(), "\{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\{\}\}")[1]92  let blank = send(&context, &Dispatch.initial(), "   ")[1]93  let oversized = only(&context, "\"" + "x".repeat(Server.MAX_LINE_BYTES) + "\"")94  let prefix = "\{\"jsonrpc\":\"2.0\",\"id\":40,\"method\":\"tools/list\",\"pad\":\""95  let exact = only(&context, prefix + "x".repeat(Server.MAX_LINE_BYTES - prefix.length() - 2) + "\"\}")96  let resourceWrongUri = only(&context, request(41, "completion/complete", modern(&[("ref", Json.object(&[("type", Json.Text("ref/resource")), ("uri", Json.Text("pudu://docs/\{slug\}"))])), ("argument", Json.object(&[("name", Json.Text("module")), ("value", Json.Text("Std"))]))])))97  let resourceWrongArgument = only(&context, request(42, "completion/complete", modern(&[("ref", Json.object(&[("type", Json.Text("ref/resource")), ("uri", Json.Text("pudu://reference/\{module\}"))])), ("argument", Json.object(&[("name", Json.Text("topic")), ("value", Json.Text("Std"))]))])))98  let promptWrongArgument = only(&context, request(43, "completion/complete", modern(&[("ref", Json.object(&[("type", Json.Text("ref/prompt")), ("name", Json.Text("pudu_explain"))])), ("argument", Json.object(&[("name", Json.Text("module")), ("value", Json.Text("o"))]))])))99  let promptAsResource = only(&context, request(44, "completion/complete", modern(&[("ref", Json.object(&[("type", Json.Text("ref/resource")), ("name", Json.Text("pudu_explain"))])), ("argument", Json.object(&[("name", Json.Text("topic")), ("value", Json.Text("o"))]))])))100  let manyModules = only(&context, request(45, "completion/complete", modern(&[("ref", Json.object(&[("type", Json.Text("ref/resource")), ("uri", Json.Text("pudu://reference/\{module\}"))])), ("argument", Json.object(&[("name", Json.Text("module")), ("value", Json.Text(""))]))])))101  let rootless = only(&context, request(46, "resources/read", modern(&[("uri", Json.Text("pudu:///errors"))])))102  let noSlash = only(&context, request(48, "resources/read", modern(&[("uri", Json.Text("pudu://noslash"))])))103  let examples = only(&context, request(47, "tools/call", modern(&[("name", Json.Text("pudu_docs_search")), ("arguments", Json.object(&[("query", Json.Text("hello")), ("group", Json.Text("examples"))]))])))104  let unknownTool = only(&context, request(11, "tools/call", modern(&[("name", Json.Text("pudu_nope"))])))105  let nameless = only(&context, request(12, "tools/call", modern(&[("name", Json.Number(1))])))106  let badArguments = only(&context, request(13, "tools/call", modern(&[("name", Json.Text("pudu_docs_search")), ("arguments", Json.object(&[("limit", Json.Number(3))]))])))107  let searched = only(&context, request(14, "tools/call", modern(&[("name", Json.Text("pudu_docs_search")), ("arguments", Json.object(&[("query", Json.Text("ownership"))]))])))108  let firstPage = only(&context, request(15, "resources/list", modern(&[])))109  let cursor = Option.unwrapOr(Access.pathText(&firstPage, &["result", "nextCursor"]), "")110  let secondPage = only(&context, request(16, "resources/list", modern(&[("cursor", Json.Text(cursor))])))111  let badCursor = only(&context, request(17, "resources/list", modern(&[("cursor", Json.Text("offset:9999"))])))112  let index = only(&context, request(18, "resources/read", modern(&[("uri", Json.Text("pudu://docs/index"))])))113  let chapter = only(&context, request(19, "resources/read", modern(&[("uri", Json.Text("pudu://docs/errors"))])))114  let missing = only(&context, request(20, "resources/read", modern(&[("uri", Json.Text("pudu://docs/nope"))])))115  let foreign = only(&context, request(21, "resources/read", modern(&[("uri", Json.Text("file:///etc/passwd"))])))116  let reference = only(&context, request(22, "resources/read", modern(&[("uri", Json.Text("pudu://reference/Std.Io"))])))117  let unknownReference = only(&context, request(23, "resources/read", modern(&[("uri", Json.Text("pudu://reference/Std.Nope"))])))118  let templates = only(&context, request(24, "resources/templates/list", modern(&[])))119  let prompts = only(&context, request(25, "prompts/list", modern(&[])))120  let prompt = only(&context, request(26, "prompts/get", modern(&[("name", Json.Text("pudu_explain")), ("arguments", Json.object(&[("topic", Json.Text("traits"))]))])))121  let promptMissing = only(&context, request(27, "prompts/get", modern(&[("name", Json.Text("pudu_explain"))])))122  let completeModule = only(&context, request(28, "completion/complete", modern(&[("ref", Json.object(&[("type", Json.Text("ref/resource")), ("uri", Json.Text("pudu://reference/\{module\}"))])), ("argument", Json.object(&[("name", Json.Text("module")), ("value", Json.Text("std.i"))]))])))123  let completeTopic = only(&context, request(29, "completion/complete", modern(&[("ref", Json.object(&[("type", Json.Text("ref/prompt")), ("name", Json.Text("pudu_explain"))])), ("argument", Json.object(&[("name", Json.Text("topic")), ("value", Json.Text("own"))]))])))124  let completeNothing = only(&context, request(30, "completion/complete", modern(&[("ref", Json.object(&[("type", Json.Text("ref/prompt")), ("name", Json.Text("pudu_review"))])), ("argument", Json.object(&[("name", Json.Text("source")), ("value", Json.Text(""))]))])))125  let completeBad = only(&context, request(31, "completion/complete", modern(&[("ref", Json.object(&[]))])))126  let referenceSearch = only(&context, request(32, "tools/call", modern(&[("name", Json.Text("pudu_reference_search")), ("arguments", Json.object(&[("query", Json.Text("read"))]))])))127  let _cleaned = Fs.removeTree(library)128  let checks = Test.suite("App.Dispatch", &[129      Test.equals("discovery lists the supported versions", &Json.path(&discover, &["result", "supportedVersions"]), &Some(Json.list(&Protocol.supportedVersions().map(|v: Str| Json.Text(v))))),130      Test.equals("discovery is cacheable", &Access.pathText(&discover, &["result", "cacheScope"]), &Some("public")),131      Test.equals("discovery names the server", &Access.pathText(&discover, &["result", "_meta", Protocol.META_SERVER_INFO, "name"]), &Some(Server.SERVER_NAME)),132      Test.equals("a request before initialize without metadata is refused", &errorCode(&unopened), &Protocol.INVALID_PARAMS),133      Test.equals("initialize answers the negotiated version", &Access.pathText(&opened[1][0], &["result", "protocolVersion"]), &Some("2025-06-18")),134      Test.equals("initialize answers server info", &Access.pathText(&opened[1][0], &["result", "serverInfo", "version"]), &Some(Server.SERVER_VERSION)),135      Test.equals("initialize opens a legacy session", &legacy.session, &Negotiation.Legacy("2025-06-18")),136      Test.that("a legacy session serves requests without metadata", Json.path(&legacyList[0], &["result", "tools"]) != None),137      Test.equals("a legacy ping answers an empty result", &Access.pathText(&legacyPing[0], &["result", "resultType"]), &Some("complete")),138      Test.equals("a second initialize renegotiates", &reopened.session, &Negotiation.Legacy("2024-11-05")),139      Test.equals("a modern initialize is not a method", &errorCode(&modernInitialize), &Protocol.METHOD_NOT_FOUND),140      Test.equals("a modern ping is not a method", &errorCode(&modernPing), &Protocol.METHOD_NOT_FOUND),141      Test.equals("an unknown method is not found", &errorCode(&unknownMethod), &Protocol.METHOD_NOT_FOUND),142      Test.equals("an unsupported version is refused with its code", &errorCode(&unsupported), &Protocol.UNSUPPORTED_PROTOCOL_VERSION),143      Test.equals("a notification is never answered", &notification.length(), &0),144      Test.equals("a client response is never answered", &clientResponse.length(), &0),145      Test.equals("a blank line is never answered", &blank.length(), &0),146      Test.equals("an oversized line is refused unparsed", &errorCode(&oversized), &Protocol.INVALID_REQUEST),147      Test.equals("an oversized line is answered with a null id", &Access.member(&oversized, "id"), &Some(Json.Null)),148      Test.equals("a line of exactly the limit is parsed", &errorCode(&exact), &Protocol.INVALID_PARAMS),149      Test.equals("a completion for another template answers none", &Access.pathInt(&resourceWrongUri, &["result", "completion", "total"]), &Some(0)),150      Test.equals("a completion for another template argument answers none", &Access.pathInt(&resourceWrongArgument, &["result", "completion", "total"]), &Some(0)),151      Test.equals("a completion for another prompt argument answers none", &Access.pathInt(&promptWrongArgument, &["result", "completion", "total"]), &Some(0)),152      Test.equals("a prompt name under a resource reference answers none", &Access.pathInt(&promptAsResource, &["result", "completion", "total"]), &Some(0)),153      Test.equals("completion answers at most one hundred values", &Access.pathInt(&manyModules, &["result", "completion", "total"]), &Some(102)),154      Test.equals("completion says more values exist", &Json.path(&manyModules, &["result", "completion", "hasMore"]), &Some(Json.Boolean(true))),155      Test.equals("completion says when every value was answered", &Json.path(&completeTopic, &["result", "completion", "hasMore"]), &Some(Json.Boolean(false))),156      Test.equals("a document address needs a group", &errorCode(&rootless), &Protocol.INVALID_PARAMS),157      Test.equals("a document address needs a slash", &errorCode(&noSlash), &Protocol.INVALID_PARAMS),158      Test.that("a group limits the search to its documents", Json.encode(&examples).contains("1. examples/") && !Json.encode(&examples).contains(". docs/")),159      Test.that("search hits are numbered in order", Json.encode(&searched).contains("\\n2. docs/")),160      Test.equals("an unknown tool is invalid params", &errorCode(&unknownTool), &Protocol.INVALID_PARAMS),161      Test.equals("a non-string tool name is invalid params", &errorCode(&nameless), &Protocol.INVALID_PARAMS),162      Test.equals("invalid arguments are a tool error, not a protocol error", &Json.path(&badArguments, &["result", "isError"]), &Some(Json.Boolean(true))),163      Test.that("a tool error names the missing argument", Json.encode(&badArguments).contains("Missing required argument 'query'")),164      Test.equals("a working tool is not an error", &Json.path(&searched, &["result", "isError"]), &Some(Json.Boolean(false))),165      Test.that("resources are paged", !cursor.isEmpty()),166      Test.equals("the next page has no further cursor", &Access.pathText(&secondPage, &["result", "nextCursor"]), &None),167      Test.equals("a cursor past the end is invalid params", &errorCode(&badCursor), &Protocol.INVALID_PARAMS),168      Test.that("the index resource lists documents", Option.unwrapOr(textOfContents(&index), "").contains("docs/errors")),169      Test.that("a document resource is its Markdown", Option.unwrapOr(textOfContents(&chapter), "").startsWith("# ")),170      Test.equals("an unknown document is invalid params", &errorCode(&missing), &Protocol.INVALID_PARAMS),171      Test.equals("an unknown document names its uri", &Access.pathText(&missing, &["error", "data", "uri"]), &Some("pudu://docs/nope")),172      Test.equals("a foreign scheme is not a resource", &errorCode(&foreign), &Protocol.INVALID_PARAMS),173      Test.that("a module reference comes from the toolchain", Option.unwrapOr(textOfContents(&reference), "").contains("fn Std.Io.read :: Str -> Result[Str, Str]")),174      Test.equals("a module reference is private to this machine", &Access.pathText(&reference, &["result", "cacheScope"]), &Some("private")),175      Test.equals("an unknown module reference is not found", &errorCode(&unknownReference), &Protocol.INVALID_PARAMS),176      Test.that("the reference template is listed", Json.encode(&templates).contains("pudu://reference/\{module\}")),177      Test.that("prompts are listed", Json.encode(&prompts).contains("pudu_write_code")),178      Test.that("a prompt renders its topic", Json.encode(&prompt).contains("traits")),179      Test.equals("a prompt missing its argument is invalid params", &errorCode(&promptMissing), &Protocol.INVALID_PARAMS),180      Test.equals("module names complete by prefix", &Json.path(&completeModule, &["result", "completion", "values"]), &Some(Json.list(&[Json.Text("Std.Io")]))),181      Test.equals("topics complete from the guide", &Json.path(&completeTopic, &["result", "completion", "values"]), &Some(Json.list(&[Json.Text("ownership")]))),182      Test.equals("an argument without completions answers none", &Access.pathInt(&completeNothing, &["result", "completion", "total"]), &Some(0)),183      Test.equals("a malformed completion is invalid params", &errorCode(&completeBad), &Protocol.INVALID_PARAMS),184      Test.that("the reference index answers searches", Json.encode(&referenceSearch).contains("Std.Io.read"))185    ])186  let ran = Test.run(&checks)187  for failure in Test.failuresOf(&ran) {188    let _reported = Io.writeErrorLine(failure)189  }190  Test.report(&ran)191}192193/// The text of a resource read's first content.194fn textOfContents(reply: &Json.Json) -> Option[Str] {195  let contents = Json.path(reply, &["result", "contents"]) ?196  let first = Json.at(&contents, 0) ?197  Access.text(&first, "text")198}199