devboy-mcp 0.27.0

MCP (Model Context Protocol) server for devboy-tools — JSON-RPC 2.0 over stdio, exposing every devboy provider as MCP tools to AI agents.
Documentation
//! Paper 3 — production [`PrefetchDispatcher`] adapter.
//!
//! Bridges [`SpeculationEngine`] to [`McpServer::execute_for_prefetch`]
//! so the speculation path runs against the same routing /
//! transparent-proxy / fallback machinery as the main flow. The
//! adapter holds an `Arc<McpServer>` (server is consumed once at
//! startup), and forwards every dispatch through the server's
//! routing engine.
//!
//! Failure modes are converted to [`PrefetchError`] variants so the
//! engine can count them as wasted prefetches without surfacing
//! them to the LLM stream.
//!
//! Wiring contract:
//!
//! 1. Build `Arc<McpServer>` once at startup.
//! 2. Construct `McpPrefetchDispatcher::new(Arc::clone(&server))`.
//! 3. `session_pipeline.with_speculation(Arc::new(dispatcher)).await`.
//! 4. From now on, `SessionPipeline::speculate_after` will dispatch
//!    real `tools/call` requests through the server, results land
//!    in the dedup cache, and the LLM's organic call collapses to L0.
//!
//! See `paper-3-tool-aware-enrichment.md` §Race-strategy for the
//! end-to-end timing of how prefetch results meet the LLM's main
//! response.
//!
//! [`SpeculationEngine`]: crate::speculation::SpeculationEngine
//! [`PrefetchDispatcher`]: crate::speculation::PrefetchDispatcher
//! [`McpServer`]: crate::server::McpServer
//! [`McpServer::execute_for_prefetch`]: crate::server::McpServer::execute_for_prefetch
//! [`PrefetchError`]: crate::speculation::PrefetchError

use std::sync::Arc;

use async_trait::async_trait;
use serde_json::Value;

use crate::protocol::ToolResultContent;
use crate::server::McpServer;
use crate::speculation::{PrefetchDispatcher, PrefetchError};

/// Production [`PrefetchDispatcher`] backed by an `Arc<McpServer>`.
pub struct McpPrefetchDispatcher {
    server: Arc<McpServer>,
}

impl McpPrefetchDispatcher {
    pub fn new(server: Arc<McpServer>) -> Self {
        Self { server }
    }
}

#[async_trait]
impl PrefetchDispatcher for McpPrefetchDispatcher {
    async fn dispatch(&self, tool_name: &str, args: Value) -> Result<String, PrefetchError> {
        let result = self
            .server
            .execute_for_prefetch(tool_name, Some(args))
            .await;

        if result.is_error == Some(true) {
            let detail = result
                .content
                .iter()
                .map(|c| match c {
                    ToolResultContent::Text { text } => text.clone(),
                })
                .next()
                .unwrap_or_default();
            return Err(PrefetchError::Rejected(detail));
        }

        // Concatenate all text chunks (provider tools often return
        // multiple Text blocks). Empty bodies are returned as
        // empty strings so the dedup cache sees a deterministic
        // value — fail-fast circuit will pick them up if needed.
        let body: String = result
            .content
            .into_iter()
            .map(|c| match c {
                ToolResultContent::Text { text } => text,
            })
            .collect::<Vec<_>>()
            .join("\n");
        Ok(body)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::layered::SessionPipeline;
    use crate::protocol::ToolCallResult;
    use devboy_format_pipeline::adaptive_config::AdaptiveConfig;
    use serde_json::json;
    use std::sync::Arc;

    /// End-to-end smoke: SpeculationEngine + McpPrefetchDispatcher
    /// + a stub server that returns a canned body for `Read`.
    /// Validates that the dispatcher honours the routing path and
    /// surfaces results back as Ok strings.
    #[tokio::test]
    async fn dispatcher_routes_through_server_and_returns_body() {
        let server = Arc::new(McpServer::new());

        // The bare McpServer has no providers; `execute_for_prefetch`
        // will fall through to the legacy_dispatch path which returns
        // an error for unknown tools — perfect for asserting the
        // failure-to-Rejected mapping below.
        let dispatcher = McpPrefetchDispatcher::new(Arc::clone(&server));

        // Unknown tool → server returns error → dispatcher converts
        // to PrefetchError::Rejected.
        let err = dispatcher
            .dispatch("Read", json!({"file_path": "/tmp/x"}))
            .await;
        assert!(matches!(err, Err(PrefetchError::Rejected(_))));
    }

    #[tokio::test]
    async fn dispatcher_blocks_internal_context_tools() {
        let server = Arc::new(McpServer::new());
        let dispatcher = McpPrefetchDispatcher::new(Arc::clone(&server));
        for tool in ["use_context", "list_contexts", "get_current_context"] {
            let r = dispatcher.dispatch(tool, json!({})).await;
            assert!(
                matches!(&r, Err(PrefetchError::Rejected(msg)) if msg.contains("internal")),
                "{tool} must be rejected as internal, got: {r:?}"
            );
        }
    }

    #[tokio::test]
    async fn session_pipeline_can_attach_real_dispatcher() {
        // Smoke: the dispatcher type satisfies the trait bound on
        // SessionPipeline::with_speculation. We don't care about the
        // dispatch outcome — the unknown tool → error path is
        // already covered.
        let server = Arc::new(McpServer::new());
        let dispatcher: Arc<dyn PrefetchDispatcher> = Arc::new(McpPrefetchDispatcher::new(server));
        let cfg = AdaptiveConfig::default();
        let _pipeline = SessionPipeline::new(cfg).with_speculation(dispatcher).await;
    }

    /// Sanity: ToolCallResult round-trips through the dispatcher
    /// without losing the body. Requires a fake provider impl —
    /// keeping it terse so we don't recreate a test server fixture.
    #[test]
    fn tool_call_result_text_fields_concatenate_with_newline() {
        let r = ToolCallResult {
            content: vec![
                ToolResultContent::Text {
                    text: "first".into(),
                },
                ToolResultContent::Text {
                    text: "second".into(),
                },
            ],
            is_error: Some(false),
        };
        let body: String = r
            .content
            .into_iter()
            .map(|c| match c {
                ToolResultContent::Text { text } => text,
            })
            .collect::<Vec<_>>()
            .join("\n");
        assert_eq!(body, "first\nsecond");
    }
}