diff --git a/Cargo.lock b/Cargo.lock index 09dc81cc..94b625a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3458,6 +3458,13 @@ dependencies = [ "zeroize", ] +[[package]] +name = "nyxid-mcp-demo" +version = "0.1.0" +dependencies = [ + "serde_json", +] + [[package]] name = "once_cell" version = "1.21.3" diff --git a/Cargo.toml b/Cargo.toml index 32fff97c..dbdf5bce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["backend", "cli"] +members = ["backend", "cli", "mcp-demo"] resolver = "3" [workspace.package] diff --git a/Dockerfile.glama b/Dockerfile.glama index c8dcdfbc..a26b21af 100644 --- a/Dockerfile.glama +++ b/Dockerfile.glama @@ -1,27 +1,31 @@ -# syntax=docker/dockerfile:1.7 - -# Glama-targeted image for NyxID's MCP server directory listing. +# Reference Dockerfile for the Glama directory listing. +# +# This file is documentation, not a build artifact: Glama's UI does not +# auto-read it from the repo. Paste the build step (the `RUN` body) and +# CMD into Glama's admin Dockerfile page to keep the deployed config in +# sync with this file. # -# Runs `nyxid mcp-demo` over stdio with a curated, hardcoded tool -# surface so the directory's scoring pipeline can introspect tools/list -# without provisioning auth, MongoDB, or a real user. The tools -# returned mirror NyxID's gateway API but are not wired to a backing -# service — production clients use the authenticated Streamable HTTP -# transport at /mcp on a real NyxID deployment. +# Builds the standalone `nyxid-mcp-demo` crate (just serde_json + std) +# instead of the full `nyxid` backend so the cargo build finishes in +# under a minute on a constrained CI runner. The full backend speaks +# the same MCP surface over Streamable HTTP at /mcp on a real NyxID +# deployment — see the `mcp-demo` crate's main.rs for the parity notes. -FROM rust:1.93-bookworm AS builder -WORKDIR /src -COPY Cargo.toml Cargo.lock ./ -COPY backend backend -COPY cli cli -# llms_txt handler bakes the playbook into the binary via include_str!. -# .dockerignore allow-lists this single file under docs/. -COPY docs/AI_AGENT_PLAYBOOK.md docs/AI_AGENT_PLAYBOOK.md -RUN cargo build --release -p nyxid +FROM debian:trixie-slim -FROM debian:bookworm-slim -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates \ +RUN set -eu \ + && apt-get update \ + && apt-get install -y --no-install-recommends build-essential ca-certificates curl git \ + && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain 1.93.0 --profile minimal \ + && git clone --depth 1 --branch main https://github.com/ChronoAIProject/NyxID /src \ + && cd /src \ + && /root/.cargo/bin/cargo build --release -p nyxid-mcp-demo \ + && strip target/release/nyxid-mcp-demo \ + && install -m0755 target/release/nyxid-mcp-demo /usr/local/bin/nyxid-mcp-demo \ + && cd / \ + && rm -rf /src /root/.cargo /root/.rustup /root/.cache \ + && apt-get purge -y --auto-remove build-essential curl git \ && rm -rf /var/lib/apt/lists/* -COPY --from=builder /src/target/release/nyxid /usr/local/bin/nyxid -ENTRYPOINT ["nyxid", "mcp-demo"] + +CMD ["nyxid-mcp-demo"] diff --git a/backend/Dockerfile b/backend/Dockerfile index b2cae9f3..93d16fd3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -13,10 +13,12 @@ ENV NYXID_GIT_HASH=${NYXID_GIT_HASH} COPY Cargo.toml Cargo.lock ./ COPY backend/Cargo.toml backend/Cargo.toml COPY cli/Cargo.toml cli/Cargo.toml +COPY mcp-demo/Cargo.toml mcp-demo/Cargo.toml # Create dummy main.rs files so cargo can resolve and compile dependencies RUN mkdir -p backend/src && echo "fn main() {}" > backend/src/main.rs \ - && mkdir -p cli/src && echo "fn main() {}" > cli/src/main.rs + && mkdir -p cli/src && echo "fn main() {}" > cli/src/main.rs \ + && mkdir -p mcp-demo/src && echo "fn main() {}" > mcp-demo/src/main.rs RUN cargo build --release --manifest-path backend/Cargo.toml --features gcp-kms # Remove the dummy build artifacts (forces recompilation of our code only) diff --git a/backend/src/main.rs b/backend/src/main.rs index 6544340e..54207033 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -14,7 +14,6 @@ mod db; mod errors; mod handlers; mod login_cli; -mod mcp_demo; mod models; mod mw; mod routes; @@ -126,28 +125,12 @@ enum Commands { /// Scan for and hard-delete orphaned user_endpoints and user_api_keys left /// over by pre-fix revoke flows. Prints a preview and prompts before deleting. CleanupOrphans(cleanup_cli::CleanupArgs), - /// Run an MCP stdio server that exposes a curated, hardcoded tool surface - /// for directory-listing introspection (Glama). Does not connect to MongoDB, - /// does not require auth, and exposes static tool definitions only. - McpDemo, } #[tokio::main] async fn main() { let cli = Cli::parse(); - // mcp-demo runs a stdio JSON-RPC server. tracing's default `fmt::layer` - // writes to stdout, which would corrupt the protocol stream — and the - // demo doesn't need dotenv or DB init either. Dispatch before any of - // that runs. - if matches!(cli.command, Some(Commands::McpDemo)) { - if let Err(error) = mcp_demo::run().await { - eprintln!("mcp-demo failed: {error}"); - std::process::exit(1); - } - return; - } - // Load environment variables from .env file (if present) dotenvy::dotenv().ok(); diff --git a/cli/Dockerfile.node b/cli/Dockerfile.node index 63e3bc72..9feb8426 100644 --- a/cli/Dockerfile.node +++ b/cli/Dockerfile.node @@ -27,6 +27,7 @@ WORKDIR /build COPY Cargo.toml Cargo.lock ./ COPY backend/ backend/ COPY cli/ cli/ +COPY mcp-demo/ mcp-demo/ COPY docs/AI_AGENT_PLAYBOOK.md docs/AI_AGENT_PLAYBOOK.md # Build only the CLI binary (includes node agent subcommand) diff --git a/mcp-demo/Cargo.toml b/mcp-demo/Cargo.toml new file mode 100644 index 00000000..3a69aa2e --- /dev/null +++ b/mcp-demo/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "nyxid-mcp-demo" +version = "0.1.0" +edition = "2024" +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "nyxid-mcp-demo" +path = "src/main.rs" + +# Deliberately tiny: this binary exists only so directory listings +# (e.g. Glama) can introspect a static `tools/list` over stdio without +# pulling in the full NyxID backend's compile graph (which times out +# in constrained CI runners). Just serde_json + std — no tokio, no +# axum, no mongodb, no rustls. +[dependencies] +serde_json = { workspace = true } diff --git a/backend/src/mcp_demo.rs b/mcp-demo/src/main.rs similarity index 67% rename from backend/src/mcp_demo.rs rename to mcp-demo/src/main.rs index 807150b2..724434d0 100644 --- a/backend/src/mcp_demo.rs +++ b/mcp-demo/src/main.rs @@ -1,139 +1,110 @@ -//! MCP stdio server with a curated, hardcoded tool surface. +//! Standalone stdio MCP demo binary for directory-listing introspection. //! -//! Used by directory listings (Glama) to introspect a fixed `tools/list` -//! response for scoring without provisioning MongoDB, OAuth, or a real -//! user. The tools mirror NyxID's gateway API but are not wired to any -//! backing service — production clients use the authenticated -//! Streamable HTTP transport at `/mcp` on a real NyxID deployment. +//! Mirrors the curated tool surface of `backend/src/mcp_demo.rs` but +//! compiles in a fraction of the time because it depends only on +//! serde_json + std (no tokio, axum, mongodb, …). Use this when a +//! constrained build pipeline (e.g. Glama's scoring runners) can't +//! finish a full `cargo build -p nyxid` within its timeout. //! -//! The transport is JSON-RPC 2.0 over newline-delimited stdin/stdout, -//! per the MCP stdio spec. Stderr is reserved for diagnostics so the -//! protocol stream stays clean. +//! Transport: newline-delimited JSON-RPC 2.0 on stdin/stdout, per the +//! MCP stdio spec. Stderr is reserved for diagnostics. -use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use std::io::{BufRead, BufReader, Write}; const PROTOCOL_VERSION: &str = "2025-03-26"; -#[derive(Debug, Deserialize)] -struct JsonRpcRequest { - #[serde(default)] - id: Option, - method: String, - #[serde(default)] - params: Value, -} - -#[derive(Debug, Serialize)] -struct JsonRpcResponse { - jsonrpc: &'static str, - id: Value, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[derive(Debug, Serialize)] -struct JsonRpcError { - code: i32, - message: String, -} - -pub async fn run() -> std::io::Result<()> { - let stdin = tokio::io::stdin(); - let mut reader = BufReader::new(stdin).lines(); - let mut stdout = tokio::io::stdout(); +fn main() -> std::io::Result<()> { + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout().lock(); + let reader = BufReader::new(stdin.lock()); - while let Some(line) = reader.next_line().await? { + for line in reader.lines() { + let line = line?; let trimmed = line.trim(); if trimmed.is_empty() { continue; } - let req: JsonRpcRequest = match serde_json::from_str(trimmed) { - Ok(req) => req, + let req: Value = match serde_json::from_str(trimmed) { + Ok(value) => value, Err(err) => { - eprintln!("mcp-demo: failed to parse request: {err}"); + eprintln!("nyxid-mcp-demo: failed to parse request: {err}"); continue; } }; // Notifications (no `id`) get no response per JSON-RPC 2.0. - let Some(id) = req.id else { + let Some(id) = req.get("id").cloned() else { continue; }; - let outcome = match req.method.as_str() { - "initialize" => Ok(handle_initialize()), - "tools/list" => Ok(handle_tools_list()), - "tools/call" => Ok(handle_tools_call(&req.params)), - "ping" => Ok(json!({})), - other => Err(JsonRpcError { - code: -32601, - message: format!("Method not found: {other}"), + let method = req.get("method").and_then(Value::as_str).unwrap_or(""); + let response = match method { + "initialize" => json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": { "tools": {} }, + "serverInfo": { + "name": "nyxid", + "version": env!("CARGO_PKG_VERSION"), + }, + }, + }), + "tools/list" => json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "tools": tool_definitions() }, + }), + "tools/call" => { + let tool_name = req + .get("params") + .and_then(|p| p.get("name")) + .and_then(Value::as_str) + .unwrap_or("(unknown)"); + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [{ + "type": "text", + "text": format!( + "This is the NyxID demo image. The '{tool_name}' tool is exposed for \ + directory introspection only and is not wired to a backing service. \ + To use NyxID's full tool surface, run an authenticated NyxID instance \ + and connect over the Streamable HTTP transport at /mcp. See \ + https://github.com/ChronoAIProject/NyxID." + ), + }], + }, + }) + } + "ping" => json!({ + "jsonrpc": "2.0", + "id": id, + "result": {}, + }), + other => json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32601, + "message": format!("Method not found: {other}"), + }, }), }; - let resp = match outcome { - Ok(result) => JsonRpcResponse { - jsonrpc: "2.0", - id, - result: Some(result), - error: None, - }, - Err(error) => JsonRpcResponse { - jsonrpc: "2.0", - id, - result: None, - error: Some(error), - }, - }; - - let serialized = serde_json::to_string(&resp)?; - stdout.write_all(serialized.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; + let serialized = serde_json::to_string(&response)?; + stdout.write_all(serialized.as_bytes())?; + stdout.write_all(b"\n")?; + stdout.flush()?; } Ok(()) } -fn handle_initialize() -> Value { - json!({ - "protocolVersion": PROTOCOL_VERSION, - "capabilities": { "tools": {} }, - "serverInfo": { - "name": "nyxid", - "version": env!("CARGO_PKG_VERSION"), - }, - }) -} - -fn handle_tools_list() -> Value { - json!({ "tools": tool_definitions() }) -} - -fn handle_tools_call(params: &Value) -> Value { - let name = params - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("(unknown)"); - json!({ - "content": [{ - "type": "text", - "text": format!( - "This is the NyxID demo image. The '{name}' tool is exposed for \ - directory introspection only and is not wired to a backing \ - service. To use NyxID's full tool surface, run an authenticated \ - NyxID instance and connect over the Streamable HTTP transport at \ - /mcp. See https://github.com/ChronoAIProject/NyxID." - ), - }], - }) -} - fn tool_definitions() -> Value { json!([ {