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>,
}
}
| Field | Type | Purpose |
|---|---|---|
config | McpConfig | API base URL (api_url) and bearer token (api_token) |
auth_context | McpAuthContext | Validated user identity (user_id, org_id, email, role) |
tool_router | ToolRouter<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:3000ALLOY_API_TOKEN— must start withalloy_live_oralloy_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:
- Creates a
reqwest::Client - Sets the
Authorization: Bearer <api_token>header - Sends the request
- Passes the response to
handle_response()for status-based error mapping
Tool implementations follow a consistent pattern:
- Extract typed parameters from
params.0 - Build a JSON body (for mutations)
- Construct the URL using
self.config.api_urland path segments - Call the appropriate
api_*method - 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:
| Prompt | Purpose | Arguments |
|---|---|---|
assign | Assign ticket to team member | ticket_id, project_key |
create-ticket | Create new ticket | project_key, title |
comment | Add comment to ticket | ticket_id |
invite | Invite user to org | email |
log-work | Interactive time logging | project_key |
move | Transition ticket status | ticket_id, status |
new-project | Create new project | name, key |
my-work | Summarize assigned tickets | — |
ping | Check connectivity | — |
plan-sprint | Review backlog, plan sprint | project_key |
project-summary | High-level project overview | project_key |
report | Generate project status report | project_key |
search | Search tickets | query |
sprint-status | Fetch active sprint burndowns | project_key |
standup | Generate daily standup summary | project_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
McpAuthContextis 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:
- Prefix check — token must start with
alloy_live_(production) oralloy_test_(testing). Rejects malformed tokens before making any network call. - API call —
GET /api/v1/auth/mewith the token as a Bearer header. The Alloy API validates the token hash against the database and returns the associated user context. - Identity resolution — the JSON response is deserialized into
McpAuthContextwithuser_id,org_id,email, androlefields.
If validation fails, the server exits immediately with an error — no MCP requests are ever processed with an invalid token.
McpAuthError Variants
| Variant | Cause |
|---|---|
MissingToken | ALLOY_API_TOKEN env var not set |
InvalidTokenFormat | Token doesn’t start with alloy_live_ or alloy_test_ |
Unauthorized | API 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 Status | MCP Error Message Prefix | Guidance |
|---|---|---|
| 200–299 | (success) | Raw JSON body returned as text content |
| 400 | Bad request | Check parameters and required fields |
| 401 | Authentication failed | Token may be invalid or expired |
| 403 | Permission denied | Insufficient role or project scope |
| 404 | Not found | Verify entity ID exists |
| 422 | Validation error | Check field values and constraints |
| Other | API 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 Pattern | Description |
|---|---|
alloy://projects | List all projects |
alloy://project/{key} | Get project by key |
alloy://ticket/{id} | Get ticket by UUID |
alloy://sprint/{id}/board | Sprint board view |
alloy://user/{id}/assigned | Tickets 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 implementationmacros—#[tool_router],#[tool_handler],#[tool]procedural macrostransport-io— stdio transport support