mod admin;
mod audit;
pub mod budget;
pub mod fence;
mod gate;
mod legal;
pub mod stdio;
mod vcons;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Instant;
use axum::Router;
use axum::body::Body;
use axum::extract::{Request, State};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use rmcp::handler::server::common::schema_for_type;
use rmcp::model::{
CallToolRequestParams, CallToolResponse, CallToolResult, Extensions, Implementation,
JsonObject, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool,
ToolAnnotations,
};
use rmcp::service::{RequestContext, RoleServer};
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
use rmcp::{ErrorData, ServerHandler};
use serde::de::DeserializeOwned;
use serde_json::Value;
pub use gate::scope;
use crate::access::{self, Caller};
use crate::api::{self, App, Problem};
use crate::audit::{Action, Actor, Entry};
use crate::ids::TenantId;
const INSTRUCTIONS: &str = "vcon-server stores vCons, encrypted at rest, and \
keeps an audit log of every action. Its tools act only within the tenant of \
the API key that calls them, and only within that key's scopes; each call is \
recorded in the audit log.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
pub calls_per_second: u32,
pub concurrent: u32,
pub response_bytes: usize,
}
impl Limits {
pub const DEFAULT: Limits = Limits {
calls_per_second: 100,
concurrent: 100,
response_bytes: 1 << 20,
};
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Hints {
pub(crate) read_only: bool,
pub(crate) destructive: bool,
pub(crate) idempotent: bool,
}
impl Hints {
pub(crate) const NONE: Hints = Hints {
read_only: false,
destructive: false,
idempotent: false,
};
}
pub(crate) fn tool<A: rmcp::schemars::JsonSchema + 'static>(
name: &'static str,
description: &'static str,
hints: Hints,
) -> Tool {
Tool::new(name, description, schema_for_type::<A>()).with_annotations(
ToolAnnotations::new()
.read_only(hints.read_only)
.destructive(hints.destructive)
.idempotent(hints.idempotent)
.open_world(false),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[must_use]
pub(crate) enum Outcome {
Arguments,
Refused(Problem),
Answered(Problem),
}
impl std::fmt::Display for Outcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Outcome::Arguments => f.write_str("the arguments do not fit the tool"),
Outcome::Refused(problem) => write!(f, "refused: {}", problem.name().1),
Outcome::Answered(problem) => write!(f, "answered: {}", problem.name().1),
}
}
}
impl std::error::Error for Outcome {}
pub(crate) type Running<'a> = Pin<Box<dyn Future<Output = Result<Value, Outcome>> + Send + 'a>>;
pub(crate) type Run = for<'a> fn(&'a Server, Caller, JsonObject) -> Running<'a>;
#[derive(Debug, Clone)]
pub(crate) struct Spec {
pub(crate) tool: Tool,
pub(crate) action: Action,
pub(crate) run: Run,
}
pub(crate) fn arguments<A: DeserializeOwned>(arguments: JsonObject) -> Result<A, Outcome> {
serde_json::from_value(Value::Object(arguments)).map_err(|_| Outcome::Arguments)
}
pub fn router(app: Arc<App>) -> Router {
router_with(app, tools())
}
pub(crate) fn tools() -> Vec<Spec> {
let mut all = vcons::specs();
all.extend(legal::specs());
all.extend(audit::specs());
all.extend(admin::specs());
all
}
pub(crate) struct Opened {
pub(crate) store: Arc<crate::store::Store>,
pub(crate) who: crate::store::Caller,
}
impl std::fmt::Debug for Opened {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Opened")
.field("who", &self.who)
.finish_non_exhaustive()
}
}
pub(crate) async fn opened(
server: &Server,
caller: Caller,
scope: access::Scope,
) -> Result<Opened, Outcome> {
let tenant =
access::tenant_for(&caller, scope).map_err(|_| Outcome::Refused(Problem::Forbidden))?;
let tenants = server.app.tenants.clone();
let store = api::blocking(move || tenants.store(tenant))
.await
.map_err(|_| Outcome::Refused(Problem::Unavailable))?;
Ok(Opened {
store,
who: crate::store::Caller { key: caller.key },
})
}
pub(crate) fn vcon_text(stored: &crate::store::Stored, limit: u64) -> Result<String, Outcome> {
use std::io::Read;
let damaged = || Outcome::Answered(Problem::Store(crate::store::StoreError::Damaged));
let text = if stored.gzip {
let mut inflated = Vec::new();
flate2::read::MultiGzDecoder::new(stored.body.as_slice())
.take(limit.saturating_add(1))
.read_to_end(&mut inflated)
.map_err(|_| damaged())?;
if inflated.len() as u64 > limit {
return Err(Outcome::Answered(Problem::TooLarge));
}
inflated
} else {
stored.body.clone()
};
let text = String::from_utf8(text).map_err(|_| damaged())?;
Ok(fence::escape_json(&text))
}
pub(crate) fn text_result(
head: Value,
text: &str,
cursor: Option<&str>,
room: usize,
) -> Result<Value, Outcome> {
let start = match cursor {
None => 0,
Some(cursor) => cursor
.parse::<usize>()
.ok()
.filter(|&at| at <= text.len() && text.is_char_boundary(at))
.ok_or(Outcome::Arguments)?,
};
let build = |piece: &str, next: Option<usize>| {
let mut result = head.clone();
result["vcon"] = fence::fence(piece).into();
result["truncated"] = next.is_some().into();
result["next_cursor"] = next.map(|at| at.to_string()).into();
result["provenance"] = fence::PROVENANCE.into();
result
};
let (piece, next) = budget::piece(text, start, |piece, more| {
budget::size(&build(piece, more.then_some(text.len()))) <= room
});
Ok(build(piece, next))
}
pub(crate) fn router_with(app: Arc<App>, specs: Vec<Spec>) -> Router {
let server = Server {
app: app.clone(),
specs: Arc::new(specs),
gate: Arc::new(gate::Gate::new(app.mcp)),
};
let config = StreamableHttpServerConfig::default()
.with_legacy_session_mode(false)
.with_json_response(true)
.with_sse_keep_alive(None)
.disable_allowed_hosts()
.enforce_origin_validation()
.with_max_request_body_bytes(app.limits.body);
let service: StreamableHttpService<Server, LocalSessionManager> =
StreamableHttpService::new(move || Ok(server.clone()), Default::default(), config);
Router::new().route(
"/mcp",
axum::routing::post_service(service)
.route_layer(axum::middleware::from_fn_with_state(app, admit)),
)
}
#[derive(Clone)]
pub(crate) struct Server {
pub(crate) app: Arc<App>,
specs: Arc<Vec<Spec>>,
gate: Arc<gate::Gate>,
}
impl std::fmt::Debug for Server {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let tools: Vec<&str> = self.specs.iter().map(|spec| &*spec.tool.name).collect();
f.debug_struct("Server")
.field("tools", &tools)
.field("gate", &self.gate)
.finish_non_exhaustive()
}
}
fn caller_of(extensions: &Extensions) -> Option<Caller> {
extensions
.get::<axum::http::request::Parts>()
.and_then(|parts| parts.extensions.get::<Caller>())
.copied()
}
fn allows(caller: &Caller, tool: &Tool) -> bool {
scope(&tool.name, tool.annotations.as_ref())
.is_some_and(|scope| access::require(caller, scope).is_ok())
}
impl Server {
async fn recorded(&self, caller: Caller, action: Action, problem: Problem) -> ErrorData {
let entry = refusal(caller, action, problem);
gate::refused(self.app.refuse(caller.tenant, entry, problem).await, None)
}
async fn counted(
&self,
caller: Caller,
action: Action,
problem: Problem,
retry: Option<std::time::Duration>,
) -> ErrorData {
if count(&self.app, caller.tenant, refusal(caller, action, problem)).await {
gate::refused(problem, retry)
} else {
gate::refused(Problem::AuditFailed, None)
}
}
}
fn refusal(caller: Caller, action: Action, problem: Problem) -> Entry {
Entry {
actor: Actor::Key(caller.key),
action,
target: None,
version: None,
status: problem.status().as_u16(),
}
}
impl ServerHandler for Server {
fn get_info(&self) -> ServerConfig {
let mut info = ServerConfig::new(ServerCapabilities::builder().enable_tools().build());
info.server_info = Implementation::new("vcon-server", env!("CARGO_PKG_VERSION"));
info.instructions = Some(INSTRUCTIONS.to_owned());
info
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
let caller = caller_of(&context.extensions);
let mut tools: Vec<Tool> = self
.specs
.iter()
.filter(|spec| caller.is_some_and(|caller| allows(&caller, &spec.tool)))
.map(|spec| spec.tool.clone())
.collect();
tools.sort_by(|a, b| a.name.cmp(&b.name));
Ok(ListToolsResult::with_all_items(tools))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, ErrorData> {
let Some(caller) = caller_of(&context.extensions) else {
return Err(gate::refused(Problem::Unauthorized, None));
};
let spec = self
.specs
.iter()
.find(|spec| spec.tool.name == request.name);
let action = spec.map_or(Action::Mcp, |spec| spec.action);
let limit = self.gate.limits.calls_per_second;
if let Err(retry) = self.gate.windows.take(caller.key, limit, Instant::now()) {
return Err(self
.counted(caller, action, Problem::RateLimited, Some(retry))
.await);
}
let Some(_slot) = self.gate.slot() else {
return Err(self
.counted(caller, action, Problem::AtCapacity, None)
.await);
};
let Some(spec) = spec else {
return Err(self.recorded(caller, action, Problem::UnknownTool).await);
};
if !allows(&caller, &spec.tool) {
return Err(self.recorded(caller, action, Problem::Forbidden).await);
}
let (run, server, arguments) = (
spec.run,
self.clone(),
request.arguments.unwrap_or_default(),
);
let ran = tokio::spawn(async move { (run)(&server, caller, arguments).await }).await;
let Ok(ran) = ran else {
let failed = Problem::Store(crate::store::StoreError::Io);
let entry = refusal(caller, action, failed);
let problem = self.app.refuse(caller.tenant, entry, failed).await;
return Ok(CallToolResult::structured_error(problem.document()).into());
};
match ran {
Ok(value) => Ok(CallToolResult::structured(value).into()),
Err(Outcome::Arguments) => Err(self
.recorded(caller, action, Problem::InvalidArguments)
.await),
Err(Outcome::Refused(problem)) => {
let entry = refusal(caller, action, problem);
let problem = self.app.refuse(caller.tenant, entry, problem).await;
Ok(CallToolResult::structured_error(problem.document()).into())
}
Err(Outcome::Answered(problem)) => {
Ok(CallToolResult::structured_error(problem.document()).into())
}
}
}
}
async fn count(app: &Arc<App>, tenant: Option<TenantId>, entry: Entry) -> bool {
let tenants = app.tenants.clone();
api::blocking(move || match tenant.and_then(|t| tenants.store(t).ok()) {
Some(store) => store.count(&entry).is_ok(),
None => tenants.count_admin(&entry).is_ok(),
})
.await
}
async fn admit(State(app): State<Arc<App>>, request: Request, next: Next) -> Response {
let (mut parts, body) = request.into_parts();
let caller = match api::authenticated(&app, &parts.headers).await {
Ok(caller) => caller,
Err(refused) => return refused.into_response(),
};
let tenants = app.tenants.clone();
let closing = caller
.tenant
.is_some_and(|tenant| tenants.store(tenant).is_err());
let answer = if closing {
Problem::Unavailable.into_response()
} else {
match api::read_body(body, &parts.headers, &app.limits).await {
Ok(body) => {
parts.extensions.insert(caller);
next.run(Request::from_parts(parts, Body::from(body))).await
}
Err(refused) => refused.into_response(),
}
};
let entry = Entry {
actor: Actor::Key(caller.key),
action: Action::Mcp,
target: None,
version: None,
status: answer.status().as_u16(),
};
if count(&app, caller.tenant, entry).await {
answer
} else {
Problem::AuditFailed.into_response()
}
}
#[cfg(test)]
mod tests;