use std::io::{BufRead, Write};
use anyhow::{Result, bail};
use serde_json::{Map, Value, json};
use crate::context;
use crate::db::{Db, Project};
use crate::paths;
const PROTOCOL_VERSION: &str = "2025-06-18";
const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
pub fn serve() -> Result<()> {
let db = Db::open(&paths::db_path()?)?;
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
let mut line = String::new();
loop {
line.clear();
if stdin.lock().read_line(&mut line)? == 0 {
return Ok(());
}
let line = line.trim();
if line.is_empty() {
continue;
}
let request: Value = match serde_json::from_str(line) {
Ok(value) => value,
Err(err) => {
eprintln!("rigger mcp: ignoring a line that is not JSON: {err}");
continue;
}
};
let Some(response) = handle(&db, &request) else {
continue;
};
writeln!(stdout, "{response}")?;
stdout.flush()?;
}
}
pub fn self_check(db: &Db) -> Result<Check> {
let request = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" });
let Some(response) = handle(db, &request) else {
bail!("the server returned nothing to a request that requires an answer");
};
if let Some(error) = response.get("error") {
bail!("the server answered with an error: {error}");
}
let tools = response
.get("result")
.and_then(|r| r.get("tools"))
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or_default();
Ok(Check {
protocol: PROTOCOL_VERSION,
version: env!("CARGO_PKG_VERSION"),
tools,
prompts: prompts().len(),
})
}
#[derive(Debug, serde::Serialize)]
pub struct Check {
pub protocol: &'static str,
pub version: &'static str,
pub tools: usize,
pub prompts: usize,
}
fn handle(db: &Db, request: &Value) -> Option<Value> {
let id = request.get("id").cloned();
let method = request.get("method").and_then(Value::as_str).unwrap_or_default();
let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
id.as_ref()?;
let id = id.unwrap_or(Value::Null);
let result = match method {
"initialize" => Ok(initialize()),
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({ "tools": tools() })),
"tools/call" => call_tool(db, ¶ms),
"prompts/list" => Ok(json!({ "prompts": prompts() })),
"prompts/get" => get_prompt(db, ¶ms),
"resources/list" => resources(db),
"resources/read" => read_resource(db, ¶ms),
other => Err(Failure {
code: METHOD_NOT_FOUND,
message: format!("rigger does not serve `{other}`"),
}),
};
Some(match result {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err(failure) => json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": failure.code, "message": failure.message },
}),
})
}
struct Failure {
code: i64,
message: String,
}
fn initialize() -> Value {
json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {
"tools": {},
"prompts": {},
"resources": {},
},
"serverInfo": {
"name": "rigger",
"version": env!("CARGO_PKG_VERSION"),
},
"instructions": "The record of this project: what is done, what is next, what waits for the owner. \
Start a session with the `context` tool, or the `start session` prompt. Record what you decide, find or \
break with `record_decision`, `record_finding`, `record_pitfall` and `record_change` as it happens, not \
at the end. Leave the next session one line with `set_next_step`. Anything only the owner can settle goes \
to `ask_owner`; anything for the plan later goes to `wish`.",
})
}
fn tool(name: &str, description: &str, properties: Value, required: &[&str]) -> Value {
json!({
"name": name,
"description": description,
"inputSchema": {
"type": "object",
"properties": properties,
"required": required,
},
})
}
fn project_arg() -> Value {
json!({ "type": "string", "description": "Project name, as `rigger project list` shows it" })
}
fn text_arg(description: &str) -> Value {
json!({ "type": "string", "description": description })
}
fn task_arg() -> Value {
json!({ "type": "string", "description": "A card's key, alias or id, to write against the card instead of a project" })
}
fn tools() -> Vec<Value> {
let record = |kind: &str, what: &str| {
tool(
&format!("record_{kind}"),
what,
json!({ "project": project_arg(), "task": task_arg(), "text": text_arg("What happened, in full sentences") }),
&["text"],
)
};
vec![
tool(
"context",
"Where the project stands: what shipped, what is being built, what waits for the owner, what happened recently, and the line the last session left behind. Read this before doing anything else in a project.",
json!({
"project": project_arg(),
"budget": { "type": "integer", "description": "Token budget for the packet; the default is 3000" },
}),
&["project"],
),
tool(
"plan",
"The stage being built and its open tasks, each with the id `close_task` takes.",
json!({ "project": project_arg() }),
&["project"],
),
record(
"decision",
"Record a decision and the reason behind it. Reasons outlive the decision, so write why, not only what.",
),
record(
"finding",
"Record something learnt about the code or the domain that the next session would otherwise learn again.",
),
record("pitfall", "Record a trap worth remembering: what looked right, what actually happened."),
record("change", "Record something that changed in the product."),
tool(
"task_find",
"Find the card a line of text means - by ticket id, by title, by case number - and say whether to take it, ask, or make a new one. Give the task exactly as it was handed over.",
json!({ "query": text_arg("The task as it was handed over: id, title, numbers, any of them") }),
&["query"],
),
tool(
"task_context",
"Where a task stands: what it is, where it is worked, and everything written against it - decisions, findings, pitfalls, the plan of edits, changes, the next step. A session on a card starts here.",
json!({
"task": { "type": "string", "description": "Card key, alias or id" },
"budget": { "type": "integer", "description": "Token budget for the packet; the default is 3000" },
}),
&["task"],
),
tool(
"record_plan",
"A step of the plan of edits for a card: what will be changed, where, in what order. Give `task`.",
json!({ "project": project_arg(), "task": task_arg(), "text": text_arg("The step, in full sentences") }),
&["text"],
),
tool(
"record_state",
"Put one line at the top of the hub's state block: where the project stands after this sitting, the way a README's «Состояние» tells it. Only when the state shifted - a release, a block closed, a plan reground.",
json!({ "project": project_arg(), "text": text_arg("One sentence, bold headline first, for the README's state block") }),
&["project", "text"],
),
tool(
"set_next_step",
"Leave the next session one line to start from. The newest one wins; it is not a list. With `task`, the line belongs to the card.",
json!({ "project": project_arg(), "task": task_arg(), "text": text_arg("The one line the next session starts from") }),
&["text"],
),
tool(
"ask_owner",
"Ask the owner something only they can settle. The question waits in the packet until they answer it - it does not reach them now, so do not block on it.",
json!({ "project": project_arg(), "text": text_arg("The question, with enough context to answer it cold") }),
&["project", "text"],
),
tool(
"wish",
"Record something to sort into the plan later.",
json!({ "project": project_arg(), "text": text_arg("What you want, and why it would help") }),
&["project", "text"],
),
tool(
"resolve",
"Answer a question waiting for the owner, or mark a wish sorted into the plan, so it leaves the packet. Ids come from the packet. Answer a question only when the owner has actually said something - this records their answer, not your guess at it.",
json!({
"project": project_arg(),
"id": { "type": "integer", "description": "Id of the question or wish, as the packet lists it" },
"answer": text_arg("What the owner decided; recorded as a decision. Omit for a wish."),
}),
&["project", "id"],
),
tool(
"set_task_status",
"Give a task of the current stage a status: new, active, waiting-handoff, frozen or done. Ids come from `plan`. `active` is the one being worked on; `waiting-handoff` is handed to someone else; `frozen` is set aside on purpose.",
json!({
"project": project_arg(),
"task": { "type": "integer", "description": "Task id, as `plan` lists it" },
"status": { "type": "string", "description": "One of new, active, waiting-handoff, frozen, done" },
}),
&["project", "task", "status"],
),
tool(
"close_task",
"Mark a task of the current stage done. Ids come from `plan`.",
json!({
"project": project_arg(),
"task": { "type": "integer", "description": "Task id, as `plan` lists it" },
}),
&["project", "task"],
),
tool(
"doc_show",
"Read a handwritten text of the project: its vision, its rituals, a research note. \
Without a `slug`, lists what there is.",
json!({
"project": project_arg(),
"slug": { "type": "string", "description": "Which document; omit to list them" },
}),
&["project"],
),
tool(
"doc_write",
"Write a handwritten text into the record: a research note you were asked to make, \
or a correction to the vision. Replaces the whole body of the document, so read it \
with `doc_show` first and send back the whole of it.",
json!({
"project": project_arg(),
"slug": { "type": "string", "description": "Which document; a new one is made if there is none" },
"kind": { "type": "string", "description": "vision, decisions, research, rituals or other; only for a new one" },
"title": { "type": "string", "description": "What it is called; only needed for a new one" },
"body": { "type": "string", "description": "The whole text of the document, in markdown" },
}),
&["project", "slug", "body"],
),
]
}
fn call_tool(db: &Db, params: &Value) -> Result<Value, Failure> {
let name = params.get("name").and_then(Value::as_str).ok_or_else(|| Failure {
code: INVALID_PARAMS,
message: "a tool call needs a `name`".into(),
})?;
let empty = Map::new();
let args = params.get("arguments").and_then(Value::as_object).unwrap_or(&empty);
Ok(match run_tool(db, name, args) {
Ok(text) => json!({ "content": [{ "type": "text", "text": text }] }),
Err(err) => json!({
"content": [{ "type": "text", "text": format!("{err:#}") }],
"isError": true,
}),
})
}
fn run_tool(db: &Db, name: &str, args: &Map<String, Value>) -> Result<String> {
let card = |db: &Db| -> Result<Option<crate::card::Card>> {
match args.get("task").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty()) {
Some(text) => match db.card_by_ref(text)? {
Some(card) => Ok(Some(card)),
None => bail!("no card named '{text}'; `task_find` looks one up"),
},
None => Ok(None),
}
};
let project = |db: &Db| -> Result<Project> {
if card(db)?.is_some() {
return db.desk_project();
}
let Some(name) = args.get("project").and_then(Value::as_str) else {
bail!("this tool needs a `project`, or a `task` to write against");
};
match db.project_by_name(name)? {
Some(project) => Ok(project),
None => bail!("no project named '{name}'; the projects are listed by `rigger project list`"),
}
};
let text = || -> Result<&str> {
match args.get("text").and_then(Value::as_str) {
Some(text) if !text.trim().is_empty() => Ok(text.trim()),
Some(_) => bail!("`text` is empty; a record with nothing in it helps nobody"),
None => bail!("this tool needs a `text`"),
}
};
match name {
"context" => {
let project = project(db)?;
let budget = args
.get("budget")
.and_then(Value::as_u64)
.map(|b| b as usize)
.unwrap_or(context::DEFAULT_BUDGET);
Ok(context::render(&context::build(db, &project, budget)?))
}
"plan" => {
let project = project(db)?;
Ok(render_plan(db, &project)?)
}
"task_find" => {
let Some(query) = args.get("query").and_then(Value::as_str).map(str::trim).filter(|q| !q.is_empty()) else {
bail!("this tool needs a `query`: the task as it was handed over");
};
let parsed = crate::card::parse(query);
let cards = db.cards(None)?;
let (hits, verdict) = crate::card::find(&parsed, &cards, 8);
let mut out = match verdict {
crate::card::Verdict::Take => format!("take {}: it is the one.\n", hits[0].key),
crate::card::Verdict::Ask => "ask: show these and let the owner pick, or say it is new.\n".to_string(),
crate::card::Verdict::New => "new: nothing is close; make a card with `rigger task new`.\n".to_string(),
};
for h in &hits {
out.push_str(&format!("{:>3} {} {} {} ({})\n", h.score, h.key, h.status, h.title, h.why));
}
let shown: Vec<String> = hits.iter().map(|h| h.key.clone()).collect();
for needle in parsed.ids.iter().chain(parsed.numbers.iter()) {
for (key, title) in db.cards_mentioning(needle, &shown)? {
out.push_str(&format!("mentioned {needle}: {key} - {title}\n"));
}
}
Ok(out)
}
"task_context" => {
let Some(card) = card(db)? else {
bail!("this tool needs a `task`: a card's key, alias or id")
};
let budget = args
.get("budget")
.and_then(Value::as_u64)
.map(|b| b as usize)
.unwrap_or(crate::context::DEFAULT_BUDGET);
let first = "This is where the task stands, from rigger. Pick up from the next step; record against it with the `task` argument \
of `record_decision`, `record_finding`, `record_pitfall`, `record_plan` and `record_change`, and leave the next line \
with `set_next_step`.\n\n";
Ok(first.to_string() + &crate::render_card(db, &card, budget)?)
}
"resolve" => {
let project = project(db)?;
let Some(id) = args.get("id").and_then(Value::as_i64) else {
bail!("this tool needs an `id`; the packet lists them");
};
let answer = args.get("answer").and_then(Value::as_str).map(str::trim).filter(|a| !a.is_empty());
let (kind, body) = db.resolve_event(project.id, id, answer)?;
let first_line = body.lines().next().unwrap_or(&body);
Ok(match (kind.as_str(), answer) {
("question", Some(_)) => format!("Answered [{id}]: {first_line}\nThe answer is recorded as a decision."),
("question", None) => format!("Closed question [{id}]: {first_line}"),
_ => format!("Sorted [{id}]: {first_line}"),
})
}
"set_task_status" => {
let project = project(db)?;
let Some(task) = args.get("task").and_then(Value::as_i64) else {
bail!("this tool needs a `task` id; `plan` lists them");
};
let Some(status) = args.get("status").and_then(Value::as_str) else {
bail!("this tool needs a `status`: one of {}", crate::db::TASK_STATUSES[..5].join(", "));
};
let (title, was) = db.set_task_status(project.id, task, status)?;
Ok(match was == status {
true => format!("Task {task} was already {status}: {title}"),
false => format!("Task {task} is now {status} (was {was}): {title}"),
})
}
"doc_show" => {
let project = project(db)?;
let Some(slug) = args.get("slug").and_then(Value::as_str) else {
let docs = db.documents(project.id, None)?;
if docs.is_empty() {
return Ok(format!("{} has no documents yet.", project.name));
}
let mut out = String::new();
for d in &docs {
out.push_str(&format!("{} ({}) - {}\n", d.slug, d.kind, d.title));
}
return Ok(out);
};
let Some(doc) = db.document(project.id, slug)? else {
let known = db.documents(project.id, None)?;
let names = known.iter().map(|d| d.slug.as_str()).collect::<Vec<_>>().join(", ");
bail!("{} has no document at '{slug}'; it has {names}", project.name);
};
Ok(doc.body)
}
"doc_write" => {
let project = project(db)?;
let Some(slug) = args.get("slug").and_then(Value::as_str) else {
bail!("this tool needs a `slug`; `doc_show` lists them");
};
let Some(body) = args.get("body").and_then(Value::as_str) else {
bail!("this tool needs a `body`: the whole text of the document");
};
if body.trim().is_empty() {
bail!("the body is empty; a document is removed with `rigger doc remove`, not by emptying it");
}
let existing = db.document(project.id, slug)?;
let kind = args
.get("kind")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| existing.as_ref().map(|d| d.kind.clone()))
.unwrap_or_else(|| "other".to_string());
let title = args
.get("title")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| existing.as_ref().map(|d| d.title.clone()))
.unwrap_or_else(|| slug.to_string());
crate::doc::check_kind(&kind)?;
let written = db.write_document(project.id, &kind, slug, &title, body)?;
Ok(match existing {
Some(_) => format!("Rewrote {} ({}), {} bytes", written.slug, written.kind, written.body.len()),
None => format!("Wrote {} ({}), {} bytes", written.slug, written.kind, written.body.len()),
})
}
"close_task" => {
let project = project(db)?;
let Some(task) = args.get("task").and_then(Value::as_i64) else {
bail!("this tool needs a `task` id; `plan` lists them");
};
let (title, change) = db.close_task(project.id, task)?;
Ok(match change {
crate::db::Change::Unchanged => format!("Task {task} was already done: {title}"),
_ => format!("Closed task {task}: {title}"),
})
}
_ => {
let kind = match name {
"record_decision" => "decision",
"record_finding" => "finding",
"record_pitfall" => "pitfall",
"record_change" => "change",
"set_next_step" => "next",
"ask_owner" => "question",
"wish" => "wish",
"record_state" => "state",
"record_plan" => "plan",
other => bail!("rigger serves no tool named `{other}`"),
};
let project = project(db)?;
let text = text()?;
if kind == "state" {
db.add_state_line(project.id, &crate::db::today(), text)?;
return Ok(format!("Added a state line for {}; `rigger export` writes it into the README.", project.name));
}
if let Some(card) = card(db)? {
db.record_task_event(project.id, card.id, kind, text, &crate::db::now(), "assistant")?;
return Ok(match kind {
"next" => format!("The next session on {} starts from this line.", card.key),
_ => format!("Recorded a {kind} on {}.", card.key),
});
}
db.record_event(project.id, kind, text, &crate::db::now(), "assistant")?;
Ok(match kind {
"next" => format!("The next session for {} starts from this line.", project.name),
"question" => format!("Asked the owner; it waits in {}'s packet until they answer.", project.name),
"wish" => format!("Recorded a wish for {}.", project.name),
_ => format!("Recorded a {kind} for {}.", project.name),
})
}
}
}
fn render_plan(db: &Db, project: &Project) -> Result<String> {
let Some(stage) = db.current_stage(project.id)? else {
return Ok(format!("{}: no stage is planned.", project.name));
};
let mut out = format!("{} · {}", project.name, stage.version);
if let Some(title) = &stage.title {
out.push_str(&format!(" · {title}"));
}
out.push('\n');
if stage.tasks.is_empty() {
out.push_str("Every task of this stage is done.\n");
}
for task in &stage.tasks {
match task.status.as_str() {
"new" => out.push_str(&format!("- [{}] {}\n", task.id, task.title)),
status => out.push_str(&format!("- [{}] {} ({status})\n", task.id, task.title)),
}
}
Ok(out)
}
fn prompts() -> Vec<Value> {
vec![json!({
"name": "start session",
"description": "Where the project stands, as the first message of a session",
"arguments": [{
"name": "project",
"description": "Project name, as `rigger project list` shows it",
"required": true,
}],
})]
}
fn get_prompt(db: &Db, params: &Value) -> Result<Value, Failure> {
let name = params.get("name").and_then(Value::as_str).unwrap_or_default();
if name != "start session" {
return Err(Failure {
code: INVALID_PARAMS,
message: format!("rigger serves no prompt named `{name}`"),
});
}
let project = params
.get("arguments")
.and_then(|a| a.get("project"))
.and_then(Value::as_str)
.ok_or_else(|| Failure {
code: INVALID_PARAMS,
message: "the prompt needs a `project` argument".into(),
})?;
let packet = packet_for(db, project).map_err(|err| Failure {
code: INVALID_PARAMS,
message: format!("{err:#}"),
})?;
Ok(json!({
"description": format!("Where {project} stands, from rigger"),
"messages": [{
"role": "user",
"content": { "type": "text", "text": crate::open::first_message_over_mcp(&packet) },
}],
}))
}
fn resources(db: &Db) -> Result<Value, Failure> {
let projects = db.projects().map_err(|err| Failure {
code: INVALID_PARAMS,
message: format!("{err:#}"),
})?;
let resources: Vec<Value> = projects
.iter()
.map(|p| {
json!({
"uri": format!("rigger://{}/context", p.name),
"name": format!("{} context", p.name),
"description": format!("Where {} stands: shipped, current stage, open questions, recent events", p.name),
"mimeType": "text/markdown",
})
})
.collect();
Ok(json!({ "resources": resources }))
}
fn read_resource(db: &Db, params: &Value) -> Result<Value, Failure> {
let uri = params.get("uri").and_then(Value::as_str).unwrap_or_default();
let project = parse_uri(uri).ok_or_else(|| Failure {
code: INVALID_PARAMS,
message: format!("`{uri}` is not a rigger resource; they are `rigger://<project>/context`"),
})?;
let packet = packet_for(db, project).map_err(|err| Failure {
code: INVALID_PARAMS,
message: format!("{err:#}"),
})?;
Ok(json!({
"contents": [{ "uri": uri, "mimeType": "text/markdown", "text": packet }],
}))
}
fn parse_uri(uri: &str) -> Option<&str> {
let rest = uri.strip_prefix("rigger://")?;
let project = rest.strip_suffix("/context")?;
(!project.is_empty()).then_some(project)
}
fn packet_for(db: &Db, name: &str) -> Result<String> {
let Some(project) = db.project_by_name(name)? else {
bail!("no project named '{name}'; the projects are listed by `rigger project list`");
};
Ok(context::render(&context::build(db, &project, context::DEFAULT_BUDGET)?))
}
#[cfg(test)]
mod tests {
use super::*;
fn tool_named(name: &str) -> Value {
tools()
.into_iter()
.find(|t| t["name"] == name)
.unwrap_or_else(|| panic!("no tool named {name}"))
}
#[test]
fn the_stage_lists_every_tool_the_plan_promised() {
let names: Vec<String> = tools().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect();
for promised in [
"context",
"plan",
"record_decision",
"record_finding",
"record_pitfall",
"record_change",
"record_state",
"record_plan",
"task_find",
"task_context",
"set_next_step",
"ask_owner",
"wish",
"resolve",
"close_task",
"set_task_status",
] {
assert!(names.contains(&promised.to_string()), "{promised} is missing from {names:?}");
}
}
#[test]
fn every_tool_declares_the_shape_of_its_arguments() {
for tool in tools() {
let schema = &tool["inputSchema"];
assert_eq!(schema["type"], "object", "{}", tool["name"]);
let required = schema["required"].as_array().expect("required");
for name in required {
let name = name.as_str().unwrap();
assert!(
schema["properties"].get(name).is_some(),
"{} requires `{name}` without describing it",
tool["name"]
);
}
assert!(
tool["description"].as_str().is_some_and(|d| d.len() > 30),
"{} needs a description a model can act on",
tool["name"]
);
}
}
#[test]
fn close_task_takes_an_id_because_a_title_is_not_a_name() {
let schema = &tool_named("close_task")["inputSchema"];
assert_eq!(schema["properties"]["task"]["type"], "integer");
}
#[test]
fn a_notification_is_not_answered() {
let db_free = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
let dir = tempfile::tempdir().unwrap();
let db = Db::create(&dir.path().join("rigger.db")).unwrap();
assert!(handle(&db, &db_free).is_none());
}
#[test]
fn an_unknown_method_is_a_protocol_error_not_a_crash() {
let dir = tempfile::tempdir().unwrap();
let db = Db::create(&dir.path().join("rigger.db")).unwrap();
let response = handle(&db, &json!({ "jsonrpc": "2.0", "id": 1, "method": "resources/subscribe" })).unwrap();
assert_eq!(response["error"]["code"], METHOD_NOT_FOUND);
assert!(response["result"].is_null());
}
#[test]
fn a_tool_that_fails_answers_the_model_rather_than_the_client() {
let dir = tempfile::tempdir().unwrap();
let db = Db::create(&dir.path().join("rigger.db")).unwrap();
let call = json!({
"jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": { "name": "record_finding", "arguments": { "project": "nowhere", "text": "x" } },
});
let response = handle(&db, &call).unwrap();
assert!(response["error"].is_null(), "{response}");
assert_eq!(response["result"]["isError"], true);
let text = response["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("no project named 'nowhere'"), "{text}");
}
#[test]
fn an_empty_record_is_refused() {
let dir = tempfile::tempdir().unwrap();
let db = Db::create(&dir.path().join("rigger.db")).unwrap();
db.add_project("sample", "/tmp/sample", None, crate::db::Kind::Repo).unwrap();
let mut args = Map::new();
args.insert("project".into(), json!("sample"));
args.insert("text".into(), json!(" "));
let err = run_tool(&db, "record_finding", &args).unwrap_err().to_string();
assert!(err.contains("empty"), "{err}");
}
#[test]
fn a_resource_uri_names_a_project() {
assert_eq!(parse_uri("rigger://sample/context"), Some("sample"));
assert_eq!(parse_uri("rigger:///context"), None);
assert_eq!(parse_uri("https://example.com/context"), None);
assert_eq!(parse_uri("rigger://sample"), None);
}
#[test]
fn initialize_names_the_protocol_and_the_build() {
let init = initialize();
assert_eq!(init["protocolVersion"], PROTOCOL_VERSION);
assert_eq!(init["serverInfo"]["version"], env!("CARGO_PKG_VERSION"));
assert!(init["capabilities"]["tools"].is_object());
}
}