mod bind;
mod listing;
mod reply;
mod resolve;
mod runner;
#[cfg(test)]
mod tests;
use std::sync::Arc;
use rmcp::ServerHandler;
use rmcp::model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ErrorCode, ErrorData, Implementation,
InitializeResult, ListToolsResult, PaginatedRequestParams, ProgressToken, ServerCapabilities,
ServerInfo,
};
use rmcp::service::{Peer, RequestContext, RoleServer};
use crate::catalog::{CatalogHandle, Entry};
use crate::config::Config;
use crate::registry::RunRegistry;
use crate::tools::{BuiltInTool, prompt_value, tool_definitions};
#[cfg(feature = "picker")]
pub(crate) use self::listing::need_prompt_result;
use self::listing::{PAGE_LIMIT, list_prompts_result, page_start};
use self::reply::{optional_string, required_string, run_result, text_error, unknown_run};
pub use bind::PreparedTools;
pub(crate) const INSTRUCTIONS: &str = concat!(
"This server executes PromptForge prompts. It runs a prompt only when a caller names one: list_prompts reports the names it can run, and run_prompt takes one of those names. ",
prompt_value!()
);
#[cfg(feature = "picker")]
const MAX_CAPABILITY_LEN: usize = 4096;
#[cfg(feature = "picker")]
const MAX_CONCURRENT_RANKS: usize = 4;
#[cfg(feature = "picker")]
static RANK_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(MAX_CONCURRENT_RANKS);
type Reporting = (Peer<RoleServer>, ProgressToken);
#[derive(Debug, Clone)]
pub struct PromptForgeServer {
config: Arc<Config>,
catalog: Arc<CatalogHandle>,
registry: Arc<RunRegistry>,
tools: Arc<PreparedTools>,
}
impl PromptForgeServer {
#[must_use]
pub fn new(
config: Arc<Config>,
catalog: Arc<CatalogHandle>,
tools: Arc<PreparedTools>,
) -> PromptForgeServer {
let registry = Arc::new(RunRegistry::new(&config.server));
PromptForgeServer {
config,
catalog,
registry,
tools,
}
}
pub(crate) async fn dispatch(
&self,
request: CallToolRequestParams,
) -> Result<CallToolResult, ErrorData> {
self.answer(request, None).await
}
pub(crate) async fn dispatch_with_progress(
&self,
request: CallToolRequestParams,
peer: Peer<RoleServer>,
token: ProgressToken,
) -> Result<CallToolResult, ErrorData> {
self.answer(request, Some((peer, token))).await
}
async fn answer(
&self,
request: CallToolRequestParams,
reporting: Option<Reporting>,
) -> Result<CallToolResult, ErrorData> {
let generation = self.catalog.load();
let arguments = request.arguments.as_ref();
let name = request.name.as_ref();
let Some(tool) = BuiltInTool::from_name(name).filter(|tool| tool.published()) else {
return Err(ErrorData::new(
ErrorCode::METHOD_NOT_FOUND,
format!("no tool named {name}"),
None,
));
};
match tool {
BuiltInTool::ListPrompts => {
let cursor = optional_string(arguments, "cursor")?;
list_prompts_result(generation.catalog(), &cursor)
}
BuiltInTool::RunPrompt => {
let requested = required_string(arguments, "prompt")?;
let args = optional_string(arguments, "args")?;
match resolve::resolve(generation.catalog(), &requested) {
Ok(entry) => self.run(entry, &args, reporting).await,
Err(resolve::ResolveError::NotFound) => {
let wanted = resolve::normalize(&requested);
Ok(text_error(format!(
"there is no prompt named \"{requested}\", so nothing was run. {}",
resolve::nearest_first(generation.catalog(), &wanted)
)))
}
Err(resolve::ResolveError::Ambiguous) => Err(ErrorData::internal_error(
format!(
"the prompt name \"{requested}\" matched more than one enabled prompt"
),
None,
)),
}
}
BuiltInTool::CheckRun => {
let run_id = required_string(arguments, "run_id")?;
match self.registry.check(&run_id) {
Some(result) => run_result(&result),
None => Ok(text_error(unknown_run(
&run_id,
self.registry.retain_completed(),
))),
}
}
BuiltInTool::NeedPrompt => {
#[cfg(feature = "picker")]
{
let capability = required_string(arguments, "capability")?;
if capability.len() > MAX_CAPABILITY_LEN {
return Ok(text_error(format!(
"capability is {} bytes, over the {MAX_CAPABILITY_LEN}-byte limit; state it as one short imperative phrase.",
capability.len()
)));
}
let permit = RANK_SLOTS.acquire().await.map_err(|e| {
ErrorData::internal_error(format!("acquire a ranking slot: {e}"), None)
})?;
let generation = Arc::clone(&generation);
let shortlist = tokio::task::spawn_blocking(move || {
let _permit = permit;
generation.shortlist(&capability)
})
.await
.map_err(|e| {
ErrorData::internal_error(
format!("rank prompts for the capability: {e}"),
None,
)
})?;
need_prompt_result(&shortlist)
}
#[cfg(not(feature = "picker"))]
{
Err(ErrorData::new(
ErrorCode::METHOD_NOT_FOUND,
format!("no tool named {name}"),
None,
))
}
}
}
}
async fn run(
&self,
entry: &Entry,
args: &str,
reporting: Option<Reporting>,
) -> Result<CallToolResult, ErrorData> {
runner::run(
&self.config,
&self.registry,
Arc::clone(&self.tools),
entry,
args,
reporting,
)
.await
}
#[allow(clippy::unused_self)]
pub(crate) fn list_page(&self, cursor: Option<&str>) -> Result<ListToolsResult, ErrorData> {
let all = tool_definitions();
let start = page_start(cursor)?;
let end = start.saturating_add(PAGE_LIMIT).min(all.len());
let page = all.get(start..end).unwrap_or(&[]).to_vec();
let mut result = ListToolsResult::with_all_items(page);
if end < all.len() {
result.next_cursor = Some(end.to_string());
}
Ok(result)
}
}
impl ServerHandler for PromptForgeServer {
fn get_info(&self) -> ServerInfo {
InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new(
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
))
.with_instructions(INSTRUCTIONS)
}
async fn list_tools(
&self,
request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
self.list_page(request.and_then(|request| request.cursor).as_deref())
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, ErrorData> {
let result = match context.meta.get_progress_token() {
Some(token) => {
self.dispatch_with_progress(request, context.peer, token)
.await
}
None => self.dispatch(request).await,
};
result.map(CallToolResponse::Complete)
}
}
#[cfg(all(test, feature = "picker"))]
mod admission {
use super::{MAX_CONCURRENT_RANKS, RANK_SLOTS};
#[test]
fn ranking_admission_is_bounded_to_a_small_permit_count() {
let mut held = Vec::new();
for _ in 0..MAX_CONCURRENT_RANKS {
held.push(
RANK_SLOTS
.try_acquire()
.expect("a permit is free within the bound"),
);
}
assert!(
RANK_SLOTS.try_acquire().is_err(),
"ranking beyond the bound waits for a returned permit"
);
drop(held);
assert!(
RANK_SLOTS.try_acquire().is_ok(),
"a returned permit admits the next ranking"
);
}
}