use std::sync::Arc;
use anyhow::{Context as _, Result};
use nb_api::{Config as NbConfig, NbClient, derive_git_notebook_name};
use rmcp::{
ErrorData as McpError, ServiceExt,
handler::server::wrapper::Parameters,
model::{CallToolResult, ServerCapabilities, ServerInfo},
tool, tool_handler, tool_router,
transport::stdio,
};
use crate::mcp::errors::{operation_result, validation_result};
use crate::mcp::params::{
CreateArgs, DisplayArgs, MergeArgs, RenderArgs, ReviewArgs, ValidateArgs,
};
#[derive(Clone, Debug)]
pub struct McpConfiguration {
pub notebook: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum NotebookSource {
Explicit,
GitDerived,
}
impl std::fmt::Display for NotebookSource {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NotebookSource::Explicit => formatter.write_str("explicit"),
NotebookSource::GitDerived => formatter.write_str("git-derived"),
}
}
}
pub struct McpContext {
pub notebook: String,
pub source: NotebookSource,
pub client: NbClient,
}
pub struct McpServer {
pub context: Arc<McpContext>,
}
impl std::fmt::Debug for McpServer {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("McpServer")
.field("notebook", &self.context.notebook)
.field("source", &self.context.source)
.finish()
}
}
impl McpServer {
pub async fn new(configuration: &McpConfiguration) -> Result<Self> {
let (notebook, source) = resolve_notebook(&configuration.notebook)?;
eprintln!("nbspec-mcp: notebook={notebook} source={source}");
let nb_config = NbConfig {
notebook: Some(notebook.clone()),
..NbConfig::default()
};
let client = NbClient::new(&nb_config).context("failed to construct nb client")?;
validate_notebook_exists(&client, ¬ebook).await?;
Ok(Self {
context: Arc::new(McpContext {
notebook,
source,
client,
}),
})
}
}
pub fn resolve_notebook(explicit: &Option<String>) -> Result<(String, NotebookSource)> {
if let Some(value) = explicit.as_ref() {
return Ok((value.clone(), NotebookSource::Explicit));
}
match derive_git_notebook_name() {
Some(value) => Ok((value, NotebookSource::GitDerived)),
None => Err(anyhow::anyhow!(
"notebook not configured; pass --notebook or run within a Git \
repository with a derivable notebook name"
)),
}
}
async fn validate_notebook_exists(client: &NbClient, notebook: &str) -> Result<()> {
let listing = client.notebooks().await?;
if listing.lines().any(|line| line.trim() == notebook) {
return Ok(());
}
Err(anyhow::anyhow!(
"resolved notebook {notebook:?} is not configured in nb; \
create it with `nb notebooks add {notebook}` before starting the server"
))
}
#[tool_router]
impl McpServer {
#[tool(
name = "create",
description = "Creates a change namespace in the project notebook. \
Maps to the `nbspec create` CLI verb. Scaffolds \
proposals/<change-id>/ with a populated meta note, a \
work todo note, artifact notes, and artifact \
subfolders per the resolved schema. Writes nothing \
to the repository working tree."
)]
async fn create(
&self,
Parameters(args): Parameters<CreateArgs>,
) -> Result<CallToolResult, McpError> {
let output = crate::operations::create(
&self.context.client,
Some(&self.context.notebook),
&args.change_id,
args.title.as_deref(),
)
.await;
operation_result(output)
}
#[tool(
name = "display",
description = "Displays a change: status summary by default, note \
contents with full=true. Maps to the `nbspec display` \
CLI verb. Reports the meta summary, artifact \
readiness against the schema requires graph, work \
todo progress, and drift against current merge \
targets."
)]
async fn display(
&self,
Parameters(args): Parameters<DisplayArgs>,
) -> Result<CallToolResult, McpError> {
let output = crate::operations::display(
&self.context.client,
Some(&self.context.notebook),
&args.change_id,
args.full,
)
.await;
operation_result(output)
}
#[tool(
name = "validate",
description = "Validates a change against the OpenSpec grammar and \
its schema. Maps to the `nbspec validate` CLI verb. \
Returns text plus structured data: on success, a \
valid:true payload with summary counts; on failure, \
valid:false plus a diagnostics array carrying one \
entry per violation in note:line: [artifact] message \
form, anchored to notebook notes. Use this to \
dry-run a change before render or merge."
)]
async fn validate(
&self,
Parameters(args): Parameters<ValidateArgs>,
) -> Result<CallToolResult, McpError> {
let output = crate::operations::validate(
&self.context.client,
Some(&self.context.notebook),
&args.change_id,
)
.await;
validation_result(output)
}
#[tool(
name = "render",
description = "Renders a change to a scratch workspace for review. \
Maps to the `nbspec render` CLI verb. The diff=true \
parameter emits a unified diff against current merge \
targets, suitable for piping to review tooling such \
as difit. The repository working tree is never \
modified."
)]
async fn render(
&self,
Parameters(args): Parameters<RenderArgs>,
) -> Result<CallToolResult, McpError> {
let output = crate::operations::render(
&self.context.client,
Some(&self.context.notebook),
&args.change_id,
args.diff,
)
.await;
operation_result(output)
}
#[tool(
name = "merge",
description = "Transfers a change's durable artifacts into the \
repository. Maps to the `nbspec merge` CLI verb. \
This is the only nbspec operation that writes to \
the repository working tree. Planning collects \
every violation before any write; force=true \
overrides target-state refusals (drift, unmanaged, \
foreign ownership) but never unsupported-delta \
refusals (MODIFIED / REMOVED / RENAMED) or \
non-file occupants."
)]
async fn merge(
&self,
Parameters(args): Parameters<MergeArgs>,
) -> Result<CallToolResult, McpError> {
let output = crate::operations::merge(
&self.context.client,
Some(&self.context.notebook),
&args.change_id,
args.force,
)
.await;
operation_result(output)
}
#[tool(
name = "review",
description = "Records a review verdict binding the change's \
current rendered content. Maps to the `nbspec \
review` CLI verb. The verdict binds the aggregate \
content hash of the full rendered artifact set; \
any subsequent edit stales it. Each verdict is \
one immutable note under the change's verdicts/ \
subfolder; recording never modifies existing \
verdicts and never transitions lifecycle. A \
revise verdict REQUIRES a comment naming the \
findings. The comment string is recorded \
verbatim."
)]
async fn review(
&self,
Parameters(args): Parameters<ReviewArgs>,
) -> Result<CallToolResult, McpError> {
let output = crate::operations::review(
&self.context.client,
Some(&self.context.notebook),
&args.change_id,
&args.gate,
args.verdict,
args.reviewer.as_deref(),
args.comment.as_deref(),
)
.await;
operation_result(output)
}
}
#[tool_handler]
impl rmcp::ServerHandler for McpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
"nbspec MCP server wrapping the operations library. \
Exposes one tool per CLI verb: create, display, validate, \
render, merge, review. The notebook is resolved once at \
startup (--notebook flag wins; otherwise git-derived) and \
held for the server lifetime; per-call notebook overrides \
are not honored. `render` and `merge` mutate the scratch \
workspace and the repository working tree respectively; \
use `validate` to dry-run a change before either, and \
`review` to record the content-bound verdict that merge's \
review gate requires.",
)
}
}
pub async fn run(configuration: McpConfiguration) -> Result<()> {
let server = McpServer::new(&configuration).await?;
let service = server.serve(stdio()).await?;
service.waiting().await?;
Ok(())
}