github-copilot-sdk 1.0.0

Rust SDK for programmatic control of the GitHub Copilot CLI via JSON-RPC.
use std::collections::HashMap;
use std::sync::Arc;

use async_trait::async_trait;
use github_copilot_sdk::hooks::{
    HookContext, PreMcpToolCallInput, PreMcpToolCallOutput, SessionHooks,
};
use github_copilot_sdk::{McpServerConfig, McpStdioServerConfig};
use serde_json::{Value, json};
use tokio::sync::mpsc;

use super::support::{assistant_message_content, recv_with_timeout, with_e2e_context};

fn meta_echo_mcp_servers(repo_root: &std::path::Path) -> HashMap<String, McpServerConfig> {
    let harness_dir = repo_root.join("test").join("harness");
    let server_path = harness_dir
        .join("test-mcp-meta-echo-server.mjs")
        .to_string_lossy()
        .to_string();
    HashMap::from([(
        "meta-echo".to_string(),
        McpServerConfig::Stdio(McpStdioServerConfig {
            tools: Some(vec!["*".to_string()]),
            command: if cfg!(windows) {
                "node.exe".to_string()
            } else {
                "node".to_string()
            },
            args: vec![server_path],
            working_directory: Some(harness_dir.to_string_lossy().to_string()),
            ..McpStdioServerConfig::default()
        }),
    )])
}

struct SetMetaHooks {
    tx: mpsc::UnboundedSender<PreMcpToolCallInput>,
}

#[async_trait]
impl SessionHooks for SetMetaHooks {
    async fn on_pre_mcp_tool_call(
        &self,
        input: PreMcpToolCallInput,
        _ctx: HookContext,
    ) -> Option<PreMcpToolCallOutput> {
        let _ = self.tx.send(input);
        Some(PreMcpToolCallOutput {
            meta_to_use: Some(json!({"injected": "by-hook", "source": "test"})),
        })
    }
}

struct ReplaceMetaHooks {
    tx: mpsc::UnboundedSender<PreMcpToolCallInput>,
}

#[async_trait]
impl SessionHooks for ReplaceMetaHooks {
    async fn on_pre_mcp_tool_call(
        &self,
        input: PreMcpToolCallInput,
        _ctx: HookContext,
    ) -> Option<PreMcpToolCallOutput> {
        let _ = self.tx.send(input);
        Some(PreMcpToolCallOutput {
            meta_to_use: Some(json!({"completely": "replaced"})),
        })
    }
}

struct RemoveMetaHooks {
    tx: mpsc::UnboundedSender<PreMcpToolCallInput>,
}

#[async_trait]
impl SessionHooks for RemoveMetaHooks {
    async fn on_pre_mcp_tool_call(
        &self,
        input: PreMcpToolCallInput,
        _ctx: HookContext,
    ) -> Option<PreMcpToolCallOutput> {
        let _ = self.tx.send(input);
        Some(PreMcpToolCallOutput {
            meta_to_use: Some(Value::Null),
        })
    }
}

#[tokio::test]
async fn should_set_meta_via_premcptoolcall_hook() {
    with_e2e_context(
        "pre_mcp_tool_call_hook",
        "should_set_meta_via_premcptoolcall_hook",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let (tx, mut rx) = mpsc::unbounded_channel();
                let client = ctx.start_client().await;
                let session = client
                    .create_session(
                        ctx.approve_all_session_config()
                            .with_mcp_servers(meta_echo_mcp_servers(ctx.repo_root()))
                            .with_hooks(Arc::new(SetMetaHooks { tx })),
                    )
                    .await
                    .expect("create session");

                let answer = session
                    .send_and_wait(
                        "Use the meta-echo/echo_meta tool with value 'test-set'. Reply with just the raw tool result.",
                    )
                    .await
                    .expect("send")
                    .expect("assistant message");
                let content = assistant_message_content(&answer);
                assert!(
                    content.contains("injected"),
                    "Expected 'injected' in response, got: {content}"
                );
                assert!(
                    content.contains("by-hook"),
                    "Expected 'by-hook' in response, got: {content}"
                );

                let input = recv_with_timeout(&mut rx, "preMcpToolCall hook").await;
                assert_eq!(input.server_name, "meta-echo");
                assert_eq!(input.tool_name, "echo_meta");
                assert!(!input.working_directory.as_os_str().is_empty());
                assert!(input.timestamp > 0);

                session.disconnect().await.expect("disconnect session");
                client.stop().await.expect("stop client");
            })
        },
    )
    .await;
}

#[tokio::test]
async fn should_replace_meta_via_premcptoolcall_hook() {
    with_e2e_context(
        "pre_mcp_tool_call_hook",
        "should_replace_meta_via_premcptoolcall_hook",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let (tx, mut rx) = mpsc::unbounded_channel();
                let client = ctx.start_client().await;
                let session = client
                    .create_session(
                        ctx.approve_all_session_config()
                            .with_mcp_servers(meta_echo_mcp_servers(ctx.repo_root()))
                            .with_hooks(Arc::new(ReplaceMetaHooks { tx })),
                    )
                    .await
                    .expect("create session");

                let answer = session
                    .send_and_wait(
                        "Use the meta-echo/echo_meta tool with value 'test-replace'. Reply with just the raw tool result.",
                    )
                    .await
                    .expect("send")
                    .expect("assistant message");
                let content = assistant_message_content(&answer);
                assert!(
                    content.contains("completely"),
                    "Expected 'completely' in response, got: {content}"
                );
                assert!(
                    content.contains("replaced"),
                    "Expected 'replaced' in response, got: {content}"
                );

                let input = recv_with_timeout(&mut rx, "preMcpToolCall hook").await;
                assert_eq!(input.server_name, "meta-echo");
                assert_eq!(input.tool_name, "echo_meta");

                session.disconnect().await.expect("disconnect session");
                client.stop().await.expect("stop client");
            })
        },
    )
    .await;
}

#[tokio::test]
async fn should_remove_meta_via_premcptoolcall_hook() {
    with_e2e_context(
        "pre_mcp_tool_call_hook",
        "should_remove_meta_via_premcptoolcall_hook",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let (tx, mut rx) = mpsc::unbounded_channel();
                let client = ctx.start_client().await;
                let session = client
                    .create_session(
                        ctx.approve_all_session_config()
                            .with_mcp_servers(meta_echo_mcp_servers(ctx.repo_root()))
                            .with_hooks(Arc::new(RemoveMetaHooks { tx })),
                    )
                    .await
                    .expect("create session");

                let answer = session
                    .send_and_wait(
                        "Use the meta-echo/echo_meta tool with value 'test-remove'. Reply with just the raw tool result.",
                    )
                    .await
                    .expect("send")
                    .expect("assistant message");
                let content = assistant_message_content(&answer);
                assert!(
                    content.contains("\"meta\":null"),
                    "Expected '\"meta\":null' in response, got: {content}"
                );
                assert!(
                    content.contains("test-remove"),
                    "Expected 'test-remove' in response, got: {content}"
                );

                let input = recv_with_timeout(&mut rx, "preMcpToolCall hook").await;
                assert_eq!(input.server_name, "meta-echo");
                assert_eq!(input.tool_name, "echo_meta");

                session.disconnect().await.expect("disconnect session");
                client.stop().await.expect("stop client");
            })
        },
    )
    .await;
}