Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

MCP Server Internals

The Alloy MCP server (crates/alloy-mcp/) is an MCP-protocol server that exposes Alloy’s project management capabilities to AI assistants. It runs as an independent binary, communicates with the Alloy API over HTTP (via reqwest), and speaks the MCP protocol over stdio.

AlloyMcp Struct

The core server handler lives in crates/alloy-mcp/src/lib.rs:

#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct AlloyMcp {
    config: McpConfig,
    auth_context: McpAuthContext,
    tool_router: ToolRouter<Self>,
}
}
FieldTypePurpose
configMcpConfigAPI base URL (api_url) and bearer token (api_token)
auth_contextMcpAuthContextValidated user identity (user_id, org_id, email, role)
tool_routerToolRouter<Self>Macro-generated tool registry

McpConfig is loaded from environment variables at startup:

#![allow(unused)]
fn main() {
impl McpConfig {
    pub fn from_env() -> Result<Self, String> {
        let api_url = std::env::var("ALLOY_API_URL")?;
        let api_token = std::env::var("ALLOY_API_TOKEN")?;
        Ok(Self { api_url, api_token })
    }
}
}

Required env vars:

  • ALLOY_API_URL — e.g., http://localhost:3000
  • ALLOY_API_TOKEN — must start with alloy_live_ or alloy_test_

Tool Router Composition

Tools are organized into domain-specific modules, each annotated with the #[tool_router] macro from rmcp. The AlloyMcp::new() constructor composes them additively using the + operator:

#![allow(unused)]
fn main() {
impl AlloyMcp {
    pub fn new(config: McpConfig, auth_context: McpAuthContext) -> Self {
        Self {
            config,
            auth_context,
            tool_router: Self::tool_router()      // ping, whoami
                + Self::ticket_tools()             // create, list, get, update, delete tickets
                + Self::project_tools()            // create, list, get, update, delete projects
                + Self::sprint_tools()             // create, list, get, update, delete sprints
                + Self::comment_tools()            // add, list, delete comments
                + Self::label_tools()              // create, list, assign, remove labels
                + Self::report_tools()             // capitalization, burndown reports
                + Self::admin_tools(),             // invite user, list members, manage API keys
        }
    }
}
}

Each module lives in crates/alloy-mcp/src/tools/ and exports a router via the #[tool_router] attribute:

#![allow(unused)]
fn main() {
#[tool_router(router = ticket_tools, vis = "pub(crate)")]
impl AlloyMcp {
    #[tool(description = "Create a new ticket in a project")]
    async fn create_ticket(
        &self,
        params: Parameters<CreateTicketParams>,
    ) -> Result<CallToolResult, McpError> {
        let p = params.0;
        let body = serde_json::json!({"title": p.title, "reporter_id": self.auth_context.user_id});
        let url = format!("{}/api/v1/projects/{}/tickets", self.config.api_url, p.project_id);
        self.api_post(&url, &body).await
    }
}
}

The #[tool] macro generates the MCP tool schema (name, description, input schema) from the function signature and doc attributes. Parameters<T> is an rmcp wrapper that deserializes the MCP arguments object into a typed struct.

How Tools Call the API via reqwest

AlloyMcp implements five HTTP helper methods that all tools share:

#![allow(unused)]
fn main() {
async fn api_get(&self, url: &str) -> Result<CallToolResult, McpError>;
async fn api_post(&self, url: &str, body: &serde_json::Value) -> Result<CallToolResult, McpError>;
async fn api_put(&self, url: &str, body: &serde_json::Value) -> Result<CallToolResult, McpError>;
async fn api_patch(&self, url: &str, body: &serde_json::Value) -> Result<CallToolResult, McpError>;
async fn api_delete(&self, url: &str) -> Result<CallToolResult, McpError>;
}

Every request:

  1. Creates a reqwest::Client
  2. Sets the Authorization: Bearer <api_token> header
  3. Sends the request
  4. Passes the response to handle_response() for status-based error mapping

Tool implementations follow a consistent pattern:

  1. Extract typed parameters from params.0
  2. Build a JSON body (for mutations)
  3. Construct the URL using self.config.api_url and path segments
  4. Call the appropriate api_* method
  5. Return the CallToolResult (success returns the raw JSON body as text content)

How Prompts Work

Prompts are MCP slash commands that provide structured instructions to AI assistants for common workflows. They live in crates/alloy-mcp/src/prompts.rs.

Listing Prompts

prompt_list() returns a ListPromptsResult describing all available prompts with their names, descriptions, and accepted arguments:

PromptPurposeArguments
assignAssign ticket to team memberticket_id, project_key
create-ticketCreate new ticketproject_key, title
commentAdd comment to ticketticket_id
inviteInvite user to orgemail
log-workInteractive time loggingproject_key
moveTransition ticket statusticket_id, status
new-projectCreate new projectname, key
my-workSummarize assigned tickets
pingCheck connectivity
plan-sprintReview backlog, plan sprintproject_key
project-summaryHigh-level project overviewproject_key
reportGenerate project status reportproject_key
searchSearch ticketsquery
sprint-statusFetch active sprint burndownsproject_key
standupGenerate daily standup summaryproject_key

Dispatching Prompts

prompt_dispatch() routes the prompt name to the handler function:

#![allow(unused)]
fn main() {
pub(crate) fn prompt_dispatch(
    &self,
    request: GetPromptRequestParams,
) -> Result<GetPromptResult, McpError> {
    match request.name.as_str() {
        "assign" => self.get_prompt_assign(request),
        "create-ticket" => self.get_prompt_create_ticket(request),
        // ... remaining prompts ...
        _ => Err(McpError { code: ErrorCode::INVALID_REQUEST, ... }),
    }
}
}

Prompt Handler Pattern

Each handler extracts optional arguments, builds conditional instructions, and returns a GetPromptResult with PromptMessage entries:

#![allow(unused)]
fn main() {
fn get_prompt_create_ticket(&self, request: GetPromptRequestParams) -> Result<GetPromptResult, McpError> {
    let args = request.arguments.unwrap_or_default();
    let project_key = args.get("project_key").and_then(|v| v.as_str());

    let mut instructions = String::from("You are helping the user create a new ticket...\n\n");

    // Pre-fill already-provided arguments
    if let Some(key) = project_key {
        write!(instructions, "The user already specified: {key}. Do not ask again.\n\n").ok();
    }

    let mut result = GetPromptResult::new(vec![PromptMessage::new(
        PromptMessageRole::User,
        PromptMessageContent::text(instructions),
    )]);
    result.description = Some("Gather information and create a new ticket in Alloy".to_string());
    Ok(result)
}
}

Arguments that the user already provided are injected into the instructions so the AI doesn’t ask for them again. Missing arguments trigger conversational prompts.

Stdio vs HTTP Transport

The MCP server currently supports stdio transport only. The entry point is run_stdio():

#![allow(unused)]
fn main() {
pub async fn run_stdio(config: McpConfig) -> Result<(), Box<dyn std::error::Error>> {
    // Logs go to stderr (stdout is the MCP protocol channel)
    tracing_subscriber::fmt()
        .with_writer(std::io::stderr)
        .with_ansi(false)
        .init();

    // Validate token before accepting MCP requests
    let auth_context = auth::validate_api_token(&config.api_url, &config.api_token).await?;

    // Start MCP server over stdin/stdout
    let service = AlloyMcp::new(config, auth_context)
        .serve(stdio())
        .await?;

    service.waiting().await?;
    Ok(())
}
}

In stdio mode:

  • stdin/stdout carry JSON-RPC MCP protocol messages
  • stderr receives tracing/log output (to avoid corrupting the protocol stream)
  • Authentication happens once at startup — the token is validated and the resulting McpAuthContext is reused for all subsequent tool calls

HTTP transport is mentioned in code comments as a future possibility. In that mode, McpAuthContext would be resolved per-request from the incoming Bearer token rather than once at startup. This is not yet implemented.

Binary Entry Point

The main.rs is minimal — load config from env, run stdio, exit:

#[tokio::main]
async fn main() -> ExitCode {
    let config = match McpConfig::from_env() {
        Ok(c) => c,
        Err(e) => {
            eprintln!("Error: {e}");
            eprintln!("Required environment variables:");
            eprintln!("  ALLOY_API_URL    — Alloy API base URL");
            eprintln!("  ALLOY_API_TOKEN  — API key for authenticating");
            return ExitCode::FAILURE;
        }
    };

    if let Err(e) = alloy_mcp::run_stdio(config).await {
        eprintln!("Fatal: {e}");
        return ExitCode::FAILURE;
    }
    ExitCode::SUCCESS
}

Auth Validation on Startup

Before the MCP server begins accepting requests, it validates the API token by calling the Alloy API’s /api/v1/auth/me endpoint. This lives in crates/alloy-mcp/src/auth.rs:

#![allow(unused)]
fn main() {
pub async fn validate_api_token(
    api_url: &str,
    token: &str,
) -> Result<McpAuthContext, McpAuthError> {
    // 1. Validate prefix
    if !token.starts_with("alloy_live_") && !token.starts_with("alloy_test_") {
        return Err(McpAuthError::InvalidTokenFormat);
    }

    // 2. Call API to validate and resolve identity
    let client = reqwest::Client::new();
    let response = client
        .get(format!("{api_url}/api/v1/auth/me"))
        .bearer_auth(token)
        .send()
        .await
        .map_err(|e| McpAuthError::ApiError(e.to_string()))?;

    if response.status() == reqwest::StatusCode::UNAUTHORIZED {
        return Err(McpAuthError::Unauthorized);
    }

    response.json::<McpAuthContext>().await
        .map_err(|e| McpAuthError::ApiError(e.to_string()))
}
}

The validation sequence:

  1. Prefix check — token must start with alloy_live_ (production) or alloy_test_ (testing). Rejects malformed tokens before making any network call.
  2. API callGET /api/v1/auth/me with the token as a Bearer header. The Alloy API validates the token hash against the database and returns the associated user context.
  3. Identity resolution — the JSON response is deserialized into McpAuthContext with user_id, org_id, email, and role fields.

If validation fails, the server exits immediately with an error — no MCP requests are ever processed with an invalid token.

McpAuthError Variants

VariantCause
MissingTokenALLOY_API_TOKEN env var not set
InvalidTokenFormatToken doesn’t start with alloy_live_ or alloy_test_
UnauthorizedAPI returned 401 — token is invalid or revoked
ApiError(String)Network failure or unexpected response

Error Mapping

HTTP responses from the Alloy API are translated into MCP errors via handle_response():

#![allow(unused)]
fn main() {
async fn handle_response(&self, response: reqwest::Response) -> Result<CallToolResult, McpError> {
    let status = response.status();
    let body = response.text().await
        .map_err(|e| api_error(&format!("Failed to read response: {e}")))?;

    if status.is_success() {
        Ok(CallToolResult::success(vec![Content::text(body)]))
    } else {
        let message = if let Ok(err) = serde_json::from_str::<ApiErrorResponse>(&body) {
            err.error
        } else {
            body
        };
        let actionable = match status.as_u16() {
            400 => format!("Bad request: {message}. Check your parameters..."),
            401 => format!("Authentication failed: {message}. Your API token..."),
            403 => format!("Permission denied: {message}. You may not have..."),
            404 => format!("Not found: {message}. Verify the ID..."),
            422 => format!("Validation error: {message}. Check field values..."),
            _   => format!("API error (HTTP {status}): {message}"),
        };
        Err(api_error(&actionable))
    }
}
}
HTTP StatusMCP Error Message PrefixGuidance
200–299(success)Raw JSON body returned as text content
400Bad requestCheck parameters and required fields
401Authentication failedToken may be invalid or expired
403Permission deniedInsufficient role or project scope
404Not foundVerify entity ID exists
422Validation errorCheck field values and constraints
OtherAPI error (HTTP N)Generic fallback

All errors are wrapped with McpError { code: ErrorCode::INTERNAL_ERROR, ... }. The message text is designed to be actionable — it tells the AI assistant (or user) what to check, not just what went wrong.

The helper function api_error() constructs the McpError:

#![allow(unused)]
fn main() {
fn api_error(message: &str) -> McpError {
    McpError {
        code: ErrorCode::INTERNAL_ERROR,
        message: message.to_string().into(),
        data: None,
    }
}
}

ServerHandler Implementation

AlloyMcp implements rmcp::ServerHandler, which is the MCP protocol trait. The #[tool_handler] macro wires the tool_router into the handler automatically:

#![allow(unused)]
fn main() {
#[tool_handler]
impl ServerHandler for AlloyMcp {
    fn get_info(&self) -> ServerInfo {
        ServerInfo::new(
            ServerCapabilities::builder()
                .enable_tools()
                .enable_resources()
                .enable_prompts()
                .build(),
        )
        .with_server_info(Implementation::new("alloy-mcp", env!("CARGO_PKG_VERSION")))
        .with_protocol_version(ProtocolVersion::V_2024_11_05)
        .with_instructions("Alloy project-management MCP server. ...")
    }
}
}

Advertised capabilities:

  • Tools — CRUD operations on all entities (tickets, projects, sprints, etc.)
  • Resources — read-only data access via URI templates (alloy://project/{key}, alloy://ticket/{id}, etc.)
  • Prompts — 15 slash commands for guided AI workflows

Resources

MCP resources provide read-only access to Alloy data via URI patterns:

URI PatternDescription
alloy://projectsList all projects
alloy://project/{key}Get project by key
alloy://ticket/{id}Get ticket by UUID
alloy://sprint/{id}/boardSprint board view
alloy://user/{id}/assignedTickets assigned to user

read_resource() parses the URI, calls the appropriate API endpoint, and returns the JSON response as resource content.

Dependencies

[dependencies]
reqwest = { workspace = true }
rmcp = { version = "1.2", features = ["server", "macros", "transport-io"] }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

The key external dependency is rmcp 1.2 (the official Rust MCP SDK) with:

  • server — server-side protocol implementation
  • macros#[tool_router], #[tool_handler], #[tool] procedural macros
  • transport-io — stdio transport support