Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["backend", "cli"]
members = ["backend", "cli", "mcp-demo"]
resolver = "3"

[workspace.package]
Expand Down
50 changes: 27 additions & 23 deletions Dockerfile.glama
Original file line number Diff line number Diff line change
@@ -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"]
4 changes: 3 additions & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 0 additions & 17 deletions backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ mod db;
mod errors;
mod handlers;
mod login_cli;
mod mcp_demo;
mod models;
mod mw;
mod routes;
Expand Down Expand Up @@ -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();

Expand Down
1 change: 1 addition & 0 deletions cli/Dockerfile.node
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions mcp-demo/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 }
183 changes: 77 additions & 106 deletions backend/src/mcp_demo.rs → mcp-demo/src/main.rs
Original file line number Diff line number Diff line change
@@ -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<Value>,
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<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<JsonRpcError>,
}

#[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!([
{
Expand Down
Loading