From c9337a12e5a64087be760460259bdc747e49a2d6 Mon Sep 17 00:00:00 2001 From: main Date: Fri, 20 Mar 2026 21:19:07 -0400 Subject: Bootstrap minimal issue MCP --- crates/jira-at-home/src/mcp/service.rs | 336 +++++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 crates/jira-at-home/src/mcp/service.rs (limited to 'crates/jira-at-home/src/mcp/service.rs') diff --git a/crates/jira-at-home/src/mcp/service.rs b/crates/jira-at-home/src/mcp/service.rs new file mode 100644 index 0000000..fc9dbf0 --- /dev/null +++ b/crates/jira-at-home/src/mcp/service.rs @@ -0,0 +1,336 @@ +use std::io::{self, BufRead, Write}; +use std::path::{Path, PathBuf}; + +use libmcp::{Generation, SurfaceKind}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::mcp::fault::{FaultRecord, FaultStage}; +use crate::mcp::output::{ + ToolOutput, fallback_detailed_tool_output, split_presentation, tool_success, +}; +use crate::store::{ + IssueBody, IssueRecord, IssueSlug, IssueStore, SaveReceipt, StoreError, format_timestamp, +}; + +pub(crate) fn run_worker( + project_root: PathBuf, + generation: u64, +) -> Result<(), Box> { + let generation = generation_from_wire(generation); + let store = IssueStore::bind(project_root)?; + let stdin = io::stdin(); + let mut stdout = io::stdout().lock(); + let mut service = WorkerService::new(store, generation); + + for line in stdin.lock().lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let request = serde_json::from_str::(&line)?; + let response = match request { + crate::mcp::protocol::WorkerRequest::Execute { id, operation } => { + let outcome = match service.execute(operation) { + Ok(result) => crate::mcp::protocol::WorkerOutcome::Success { result }, + Err(fault) => crate::mcp::protocol::WorkerOutcome::Fault { fault }, + }; + crate::mcp::protocol::WorkerResponse { id, outcome } + } + }; + serde_json::to_writer(&mut stdout, &response)?; + stdout.write_all(b"\n")?; + stdout.flush()?; + } + + Ok(()) +} + +struct WorkerService { + store: IssueStore, + generation: Generation, +} + +impl WorkerService { + fn new(store: IssueStore, generation: Generation) -> Self { + Self { store, generation } + } + + fn execute( + &mut self, + operation: crate::mcp::protocol::WorkerOperation, + ) -> Result { + match operation { + crate::mcp::protocol::WorkerOperation::CallTool { name, arguments } => { + self.call_tool(&name, arguments) + } + } + } + + fn call_tool(&mut self, name: &str, arguments: Value) -> Result { + let operation = format!("tools/call:{name}"); + let (presentation, arguments) = + split_presentation(arguments, &operation, self.generation, FaultStage::Worker)?; + let output = match name { + "issue.save" => { + let args = deserialize::(arguments, &operation, self.generation)?; + let slug = IssueSlug::parse(args.slug) + .map_err(store_fault(self.generation, &operation))?; + let body = IssueBody::parse(args.body) + .map_err(store_fault(self.generation, &operation))?; + let receipt = self + .store + .save(slug, body) + .map_err(store_fault(self.generation, &operation))?; + issue_save_output( + &receipt, + self.store.layout().project_root.as_path(), + self.generation, + &operation, + )? + } + "issue.list" => { + let issues = self + .store + .list() + .map_err(store_fault(self.generation, &operation))?; + issue_list_output( + &issues, + self.store.layout().project_root.as_path(), + self.generation, + &operation, + )? + } + "issue.read" => { + let args = deserialize::(arguments, &operation, self.generation)?; + let slug = IssueSlug::parse(args.slug) + .map_err(store_fault(self.generation, &operation))?; + let record = self + .store + .read(slug) + .map_err(store_fault(self.generation, &operation))?; + issue_read_output( + &record, + self.store.layout().project_root.as_path(), + self.generation, + &operation, + )? + } + other => { + return Err(FaultRecord::invalid_input( + self.generation, + FaultStage::Worker, + &operation, + format!("unknown worker tool `{other}`"), + )); + } + }; + tool_success( + output, + presentation, + self.generation, + FaultStage::Worker, + &operation, + ) + } +} + +#[derive(Debug, Deserialize)] +struct IssueSaveArgs { + slug: String, + body: String, +} + +#[derive(Debug, Deserialize)] +struct IssueReadArgs { + slug: String, +} + +fn deserialize Deserialize<'de>>( + value: Value, + operation: &str, + generation: Generation, +) -> Result { + serde_json::from_value(value).map_err(|error| { + FaultRecord::invalid_input( + generation, + FaultStage::Protocol, + operation, + format!("invalid params: {error}"), + ) + }) +} + +fn store_fault( + generation: Generation, + operation: &str, +) -> impl FnOnce(StoreError) -> FaultRecord + '_ { + move |error| { + let stage = if matches!(error, StoreError::Io(_)) { + FaultStage::Store + } else { + FaultStage::Worker + }; + match error { + StoreError::InvalidSlug(_) + | StoreError::EmptyIssueBody + | StoreError::IssueNotFound(_) + | StoreError::MalformedIssueEntry(_, _) + | StoreError::MissingProjectPath(_) + | StoreError::ProjectPathNotDirectory(_) => { + FaultRecord::invalid_input(generation, stage, operation, error.to_string()) + } + StoreError::Io(_) => { + FaultRecord::internal(generation, stage, operation, error.to_string()) + } + } + } +} + +fn issue_save_output( + receipt: &SaveReceipt, + project_root: &Path, + generation: Generation, + operation: &str, +) -> Result { + let relative_path = relative_issue_path(&receipt.path, project_root); + let status = if receipt.created { + "created" + } else { + "updated" + }; + let concise = json!({ + "slug": receipt.slug, + "status": status, + "path": relative_path, + "updated_at": format_timestamp(receipt.updated_at), + }); + let full = json!({ + "slug": receipt.slug, + "status": status, + "path": relative_path, + "updated_at": format_timestamp(receipt.updated_at), + "bytes": receipt.bytes, + }); + fallback_detailed_tool_output( + &concise, + &full, + [ + format!("saved issue {}", receipt.slug), + format!("status: {status}"), + format!("path: {}", relative_issue_path(&receipt.path, project_root)), + format!("updated: {}", format_timestamp(receipt.updated_at)), + ] + .join("\n"), + None, + SurfaceKind::Mutation, + generation, + FaultStage::Worker, + operation, + ) +} + +fn issue_list_output( + issues: &[crate::store::IssueSummary], + project_root: &Path, + generation: Generation, + operation: &str, +) -> Result { + let concise_items = issues + .iter() + .map(|issue| { + json!({ + "slug": issue.slug, + "updated_at": format_timestamp(issue.updated_at), + }) + }) + .collect::>(); + let full_items = issues + .iter() + .map(|issue| { + let path = relative_issue_path( + &project_root + .join("issues") + .join(format!("{}.md", issue.slug)), + project_root, + ); + json!({ + "slug": issue.slug, + "path": path, + "updated_at": format_timestamp(issue.updated_at), + }) + }) + .collect::>(); + let mut lines = vec![format!("{} issue(s)", issues.len())]; + lines.extend(issues.iter().map(|issue| issue.slug.to_string())); + fallback_detailed_tool_output( + &json!({ "count": issues.len(), "issues": concise_items }), + &json!({ "count": issues.len(), "issues": full_items }), + lines.join("\n"), + None, + SurfaceKind::List, + generation, + FaultStage::Worker, + operation, + ) +} + +fn issue_read_output( + record: &IssueRecord, + project_root: &Path, + generation: Generation, + operation: &str, +) -> Result { + let relative_path = relative_issue_path(&record.path, project_root); + let concise = json!({ + "slug": record.slug, + "updated_at": format_timestamp(record.updated_at), + "body": record.body, + }); + let full = json!({ + "slug": record.slug, + "path": relative_path, + "updated_at": format_timestamp(record.updated_at), + "bytes": record.bytes, + "body": record.body, + }); + let concise_text = format!( + "issue {}\nupdated: {}\n\n{}", + record.slug, + format_timestamp(record.updated_at), + record.body, + ); + let full_text = Some(format!( + "issue {}\npath: {}\nupdated: {}\nbytes: {}\n\n{}", + record.slug, + relative_issue_path(&record.path, project_root), + format_timestamp(record.updated_at), + record.bytes, + record.body, + )); + fallback_detailed_tool_output( + &concise, + &full, + concise_text, + full_text, + SurfaceKind::Read, + generation, + FaultStage::Worker, + operation, + ) +} + +fn relative_issue_path(path: &Path, project_root: &Path) -> String { + path.strip_prefix(project_root).map_or_else( + |_| path.display().to_string(), + |relative| relative.display().to_string(), + ) +} + +fn generation_from_wire(raw: u64) -> Generation { + let mut generation = Generation::genesis(); + for _ in 1..raw { + generation = generation.next(); + } + generation +} -- cgit v1.2.3