nbspec 0.2.0

Notebook-first OpenSpec orchestration for spec-driven development
Documentation
//! MCP server surface for nbspec.
//!
//! The `McpServer` struct, the `#[tool_router]` impl block (with all
//! five tool handlers), the `#[tool_handler]` impl block, and the
//! `pub async fn run` entry point all live in this file. Keeping the
//! two impl blocks in the same module avoids cross-module macro
//! visibility friction: rmcp's `tool_handler` looks for the
//! `tool_router()` method generated by `tool_router` on the same type.

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,
};

/// Configuration provided when booting the MCP stdio service.
#[derive(Clone, Debug)]
pub struct McpConfiguration {
    /// Explicit notebook name; wins over the Git-derived default.
    pub notebook: Option<String>,
}

/// Source of the resolved notebook, used for startup logging so an
/// operator can see why the server picked the name it did.
#[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"),
        }
    }
}

/// State held for the lifetime of the server, shared with every tool
/// call through an [`Arc`]. The notebook is resolved once at
/// startup (explicit configuration → git-derived → error) AND
/// validated against `nb` (so a missing notebook fails fast
/// before the server starts listening). The spec's fail-fast
/// requirement is honored here: if `nb` does not know the resolved
/// notebook, the server exits with a clear error naming the
/// missing notebook, rather than failing on every subsequent
/// tool call.
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 {
    /// Constructs a server with the given configuration. Resolves
    /// the notebook exactly once (explicit configuration wins, then
    /// git-derived), validates the resolved notebook exists in
    /// `nb`, and logs the resolution so an operator can verify what
    /// the server picked up.
    ///
    /// # Errors
    ///
    /// Returns an error when no notebook can be resolved (no
    /// explicit configuration AND no git repository), or when the
    /// resolved notebook does not exist in `nb` (fail-fast per the
    /// spec scenario "WHEN the resolved notebook does not exist in
    /// nb").
    pub async fn new(configuration: &McpConfiguration) -> Result<Self> {
        let (notebook, source) = resolve_notebook(&configuration.notebook)?;
        // Logged once at startup so operators can see what the
        // server picked up. Goes to stderr (MCP stdio is separate).
        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, &notebook).await?;
        Ok(Self {
            context: Arc::new(McpContext {
                notebook,
                source,
                client,
            }),
        })
    }
}

/// Resolves the effective notebook name and records which source
/// produced it. Explicit configuration wins; otherwise the
/// Git-derived name (from `nb_api::derive_git_notebook_name`) is
/// used. Errors when neither source yields a notebook.
///
/// An explicit `Some("")` is preserved as the empty string and
/// labeled Explicit. The startup validation step then surfaces
/// it as "notebook is not configured in nb" rather than silently
/// falling through to Git derivation. This matches the CLI's
/// behavior (`nbspec --notebook ""` fails the same way), so the
/// MCP surface does not diverge from the CLI on operator-
/// supplied values.
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"
        )),
    }
}

/// Confirms the resolved notebook is known to `nb`. The check uses
/// `notebooks()` rather than `notebook_path()` because the path
/// lookup would silently succeed for any notebook name `nb` has
/// ever seen, even one that has been removed.
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 {
    /// Creates a change namespace in the project notebook (CLI:
    /// `nbspec create`).
    #[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)
    }

    /// Displays a change: status summary by default, note contents
    /// with `full` (CLI: `nbspec display [--full]`).
    #[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)
    }

    /// Validates a change against the OpenSpec grammar and its schema
    /// (CLI: `nbspec validate`). Returns text + structured data on
    /// both success and failure paths.
    #[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)
    }

    /// Renders a change to a scratch workspace for review (CLI:
    /// `nbspec render [--diff]`).
    #[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)
    }

    /// Transfers a change's durable artifacts into the repository
    /// (CLI: `nbspec merge [--force]`). This is the only nbspec
    /// operation that writes to the repository.
    #[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)
    }

    /// Records a review verdict binding the change's current content
    /// (CLI: `nbspec review`).
    #[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.",
        )
    }
}

/// Runs the MCP stdio service and blocks until shutdown.
pub async fn run(configuration: McpConfiguration) -> Result<()> {
    let server = McpServer::new(&configuration).await?;
    let service = server.serve(stdio()).await?;
    service.waiting().await?;
    Ok(())
}