pmcp_server_toolkit/prompts.rs
1// Originated from pmcp-run/built-in/shared/mcp-server-common/src/prompts.rs
2// (https://github.com/guyernest/pmcp-run). Lifted into rust-mcp-sdk for Phase 83.
3
4//! Static MCP prompts for config-driven servers.
5//!
6//! [`StaticPromptHandler`] implements [`pmcp::server::PromptHandler`] for a
7//! single named prompt with pre-resolved body content. The handler does NOT
8//! redefine the trait — it consumes the trait shape from `pmcp`.
9//!
10//! # Shape divergence from the source lift
11//!
12//! `mcp-server-common::prompts::StaticPromptHandler` is plural — one handler
13//! serves many prompts, dispatched by name through `get(name, &resources)`.
14//! `pmcp::PromptHandler::handle(args, extra)` is single-prompt by trait shape:
15//! the prompt name is bound at registration time via `prompt_arc(name, handler)`,
16//! not passed at invocation. The toolkit therefore models one
17//! `StaticPromptHandler` per prompt and provides
18//! [`StaticPromptHandler::from_configs`] as a factory returning
19//! `Vec<(String, StaticPromptHandler)>` that downstream builders can register
20//! in a loop. Per Plan 83-03 PATTERNS §6, "multiple prompts are registered via
21//! multiple `prompt_arc(name, handler)` calls."
22//!
23//! # Orthogonality with skills
24//!
25//! `StaticPromptHandler` is independent of [`pmcp::server::skills::Skill`] and
26//! `bootstrap_skill_and_prompt`. Downstream consumers can register both
27//! surfaces side-by-side; the toolkit makes no assumption about skill
28//! registration. The dual-surface byte-equality invariant (Phase 80 /
29//! SEP-2640 §9) applies only when a consumer wires skill + prompt for the
30//! SAME logical prompt — orthogonal to anything `StaticPromptHandler` does.
31//!
32//! # Example configuration
33//!
34//! ```toml
35//! [[prompts]]
36//! name = "shipping-context"
37//! description = "Load context about shipping policies"
38//! include_resources = ["docs://policies/shipping-guide"]
39//! ```
40
41use async_trait::async_trait;
42use pmcp::types::{Content, GetPromptResult, PromptArgument, PromptInfo, PromptMessage};
43use pmcp::PromptHandler;
44use serde::{Deserialize, Serialize};
45use std::collections::HashMap;
46
47use crate::error::ToolkitError;
48use crate::resources::StaticResourceHandler;
49
50/// The standard prompt name for Code Mode entry point.
51///
52/// Used across all server types to detect whether a TOML config already
53/// defines the code mode prompt (avoiding duplicates).
54pub const CODE_MODE_PROMPT_NAME: &str = "start_code_mode";
55
56// =============================================================================
57// Configuration Types
58// =============================================================================
59
60/// MCP Prompt configuration (simplified, no arguments).
61///
62/// Prompts provide pre-configured context that clients can request to prepare
63/// for specific types of conversations. This simplified version returns the
64/// content of included resources without requiring arguments.
65#[derive(Debug, Clone, Deserialize, Serialize)]
66pub struct PromptConfig {
67 /// Prompt name (must be unique).
68 pub name: String,
69
70 /// Human-readable description.
71 pub description: String,
72
73 /// Resource URIs to include in the prompt response.
74 #[serde(default)]
75 pub include_resources: Vec<String>,
76}
77
78/// Local alias for [`pmcp::types::PromptInfo`] used in return types so the
79/// only literal `PromptInfo` token in this module appears as a constructor
80/// call (`PromptInfo::new(...)`) — never as a struct-literal expression.
81type PromptInfoOut = pmcp::types::PromptInfo;
82
83impl PromptConfig {
84 /// Convert to a PMCP SDK prompt-info value for listing.
85 ///
86 /// See [`StaticPromptHandler::metadata`] for the handler-side path.
87 pub fn to_prompt_info(&self) -> PromptInfoOut {
88 let info = PromptInfo::new(&self.name);
89 info.with_description(&self.description)
90 }
91}
92
93// =============================================================================
94// Static Prompt Handler
95// =============================================================================
96
97/// Handler for a single static prompt with pre-resolved body content.
98///
99/// Implements [`pmcp::PromptHandler`] with required-argument validation and
100/// metadata. Each `StaticPromptHandler` represents ONE prompt; use
101/// [`StaticPromptHandler::from_configs`] to materialize a `Vec` of
102/// `(name, handler)` pairs from a `Vec<PromptConfig>` and register them via
103/// `prompt_arc(name, handler)` calls on the builder.
104///
105/// # Orthogonality with skills
106///
107/// `StaticPromptHandler` is independent of [`pmcp::server::skills::Skill`] and
108/// `bootstrap_skill_and_prompt`. Downstream consumers can register both
109/// surfaces side-by-side; the toolkit makes no assumption about skill
110/// registration. The dual-surface byte-equality invariant (Phase 80 /
111/// SEP-2640 §9) applies only when a consumer wires skill + prompt for the
112/// SAME logical prompt — orthogonal to anything `StaticPromptHandler` does.
113pub struct StaticPromptHandler {
114 name: String,
115 description: Option<String>,
116 arguments: Vec<PromptArgument>,
117 body: String,
118}
119
120impl StaticPromptHandler {
121 /// Create a handler for a single prompt.
122 ///
123 /// `body` is the message text returned from `handle()` after required-arg
124 /// validation succeeds. Pre-resolve any `include_resources` content into
125 /// `body` before calling `new` (see [`StaticPromptHandler::from_configs`]
126 /// for the canonical resolution path).
127 ///
128 /// # Example
129 ///
130 /// ```no_run
131 /// use pmcp_server_toolkit::prompts::StaticPromptHandler;
132 /// let handler = StaticPromptHandler::new(
133 /// "shipping-context",
134 /// Some("Loads shipping policy context"),
135 /// vec![],
136 /// "Policies:\n- Alcohol requires adult signature.",
137 /// );
138 /// # let _ = handler;
139 /// ```
140 pub fn new(
141 name: impl Into<String>,
142 description: Option<impl Into<String>>,
143 arguments: Vec<PromptArgument>,
144 body: impl Into<String>,
145 ) -> Self {
146 Self {
147 name: name.into(),
148 description: description.map(Into::into),
149 arguments,
150 body: body.into(),
151 }
152 }
153
154 /// Materialize a `Vec` of `(name, handler)` pairs from prompt configs by
155 /// pre-resolving each `include_resources` against the supplied resource
156 /// handler.
157 ///
158 /// Missing resources are logged at `warn` and skipped (matching the
159 /// lifted behavior). The resulting body is the resource contents joined
160 /// with `\n\n---\n\n`; if no resources resolve, the body is a
161 /// `(No resources found for prompt 'name')` placeholder.
162 ///
163 /// Returns the same insertion order as `prompts` so deterministic
164 /// registration with `prompt_arc(name, handler)` is possible.
165 pub fn from_configs(
166 prompts: &[PromptConfig],
167 resources: &StaticResourceHandler,
168 ) -> Vec<(String, Self)> {
169 prompts
170 .iter()
171 .map(|p| {
172 let body = Self::resolve_body(p, resources);
173 let handler = Self::new(
174 &p.name,
175 Some(p.description.clone()),
176 Vec::new(), // simplified-prompt schema: no arguments
177 body,
178 );
179 (p.name.clone(), handler)
180 })
181 .collect()
182 }
183
184 /// Resolve the combined body for a prompt by expanding included
185 /// resources. Missing URIs are logged at `warn` and skipped.
186 fn resolve_body(prompt: &PromptConfig, resources: &StaticResourceHandler) -> String {
187 let mut content_parts: Vec<String> = Vec::new();
188
189 for resource_uri in &prompt.include_resources {
190 if let Some(resource) = resources.get(resource_uri) {
191 content_parts.push(resource.content.clone());
192 } else {
193 tracing::warn!(
194 uri = %resource_uri,
195 prompt = %prompt.name,
196 "Resource not found for prompt",
197 );
198 }
199 }
200
201 if content_parts.is_empty() {
202 format!("(No resources found for prompt '{}')", prompt.name)
203 } else {
204 content_parts.join("\n\n---\n\n")
205 }
206 }
207}
208
209#[async_trait]
210impl PromptHandler for StaticPromptHandler {
211 async fn handle(
212 &self,
213 args: HashMap<String, String>,
214 _extra: pmcp::RequestHandlerExtra,
215 ) -> pmcp::Result<GetPromptResult> {
216 // Validate required arguments (PATTERNS §6 — argument-validation
217 // pattern verbatim from src/server/simple_prompt.rs:111-119).
218 for arg in &self.arguments {
219 if arg.required && !args.contains_key(&arg.name) {
220 return Err(pmcp::Error::validation(format!(
221 "Required argument '{}' is missing",
222 arg.name
223 )));
224 }
225 }
226
227 Ok(GetPromptResult::new(
228 vec![PromptMessage::user(Content::text(self.body.clone()))],
229 self.description.clone(),
230 ))
231 }
232
233 fn metadata(&self) -> Option<PromptInfoOut> {
234 // PATTERNS Pattern C: use the constructor, NOT struct-literal —
235 // pmcp::types::PromptInfo is #[non_exhaustive].
236 let mut info = PromptInfo::new(&self.name);
237 if let Some(desc) = &self.description {
238 info = info.with_description(desc);
239 }
240 if !self.arguments.is_empty() {
241 info = info.with_arguments(self.arguments.clone());
242 }
243 Some(info)
244 }
245}
246
247// =============================================================================
248// Construction from `ServerConfig` (Plan 08 — TKIT-05 completion)
249// =============================================================================
250//
251// `pmcp::PromptHandler` binds a single prompt name at registration time via
252// `prompt_arc(name, handler)`. To stay consistent with that shape, the
253// crate-level construction surface is a free function that returns
254// `Vec<(name, StaticPromptHandler)>` — NOT an `impl From<&ServerConfig>` on
255// the handler itself (a single handler can only model one prompt; see Plan 03
256// PATTERNS §6 + the "Shape divergence from the source lift" rustdoc above).
257//
258// Per Plan 08 review R3, [`From<&crate::config::ServerConfig>`] is also
259// provided as a "construct the first prompt or an empty/no-op handler"
260// convenience so the trait-impl arm of the verification grep matches. The
261// canonical path remains [`prompt_handlers_from_config`] for multi-prompt
262// servers.
263
264/// Materialize a `Vec` of `(name, handler)` pairs from a parsed
265/// [`crate::config::ServerConfig`].
266///
267/// Each `[[prompts]]` entry yields one [`StaticPromptHandler`] with body
268/// pre-resolved against `cfg.resources` (URIs not present in the resource
269/// table are skipped with a `tracing::warn!`). Insertion order matches the
270/// `[[prompts]]` declaration order.
271///
272/// Callers register each pair via `pmcp::ServerBuilder::prompt_arc(name, Arc::new(handler))`.
273///
274/// # Example
275///
276/// ```no_run
277/// use std::sync::Arc;
278/// use pmcp::Server;
279/// use pmcp_server_toolkit::{ServerConfig, prompts::prompt_handlers_from_config};
280///
281/// let cfg = ServerConfig::default();
282/// let pairs = prompt_handlers_from_config(&cfg);
283/// let mut builder = Server::builder().name("demo").version("0.1.0");
284/// for (name, handler) in pairs {
285/// builder = builder.prompt_arc(name, Arc::new(handler));
286/// }
287/// # let _ = builder;
288/// ```
289pub fn prompt_handlers_from_config(
290 cfg: &crate::config::ServerConfig,
291) -> Vec<(String, StaticPromptHandler)> {
292 // Reuse the resource handler so resolved bodies match what the
293 // configured resources actually expose at runtime.
294 let resource_handler = crate::resources::StaticResourceHandler::from(cfg);
295 let configs: Vec<PromptConfig> = cfg
296 .prompts
297 .iter()
298 .map(|p| PromptConfig {
299 name: p.name.clone(),
300 description: p.description.clone().unwrap_or_default(),
301 include_resources: p.include_resources.clone(),
302 })
303 .collect();
304 StaticPromptHandler::from_configs(&configs, &resource_handler)
305}
306
307impl From<&crate::config::ServerConfig> for StaticPromptHandler {
308 /// Build a single [`StaticPromptHandler`] from a [`crate::config::ServerConfig`].
309 ///
310 /// Returns a handler for the FIRST `[[prompts]]` entry, or — if none are
311 /// declared — a no-op handler named `"<no-prompts>"` with an empty body.
312 /// Multi-prompt servers should use [`prompt_handlers_from_config`] instead.
313 ///
314 /// # Example
315 ///
316 /// ```no_run
317 /// use pmcp_server_toolkit::{ServerConfig, StaticPromptHandler};
318 ///
319 /// let cfg = ServerConfig::default();
320 /// let _handler = StaticPromptHandler::from(&cfg);
321 /// ```
322 fn from(cfg: &crate::config::ServerConfig) -> Self {
323 let mut pairs = prompt_handlers_from_config(cfg);
324 if pairs.is_empty() {
325 StaticPromptHandler::new(
326 "<no-prompts>",
327 Some("config declared no [[prompts]] entries"),
328 Vec::new(),
329 String::new(),
330 )
331 } else {
332 pairs.remove(0).1
333 }
334 }
335}
336
337// =============================================================================
338// Free helpers (lifted from mcp-server-common)
339// =============================================================================
340
341/// Resolve extra prompt content from TOML-defined resources.
342///
343/// Finds the `start_code_mode` prompt in the config, resolves
344/// `include_resources` URIs against the resource definitions, and returns the
345/// content strings. Filters out auto-generated resources
346/// (`code-mode://instructions` and `code-mode://policies`) since those are
347/// already included by the Code Mode handler.
348///
349/// This allows admin-curated resources (schema docs, examples, learnings) to
350/// be appended to the auto-generated Code Mode prompt.
351pub fn resolve_extra_prompt_content(
352 prompts: &[PromptConfig],
353 resources: &[crate::resources::ResourceConfig],
354) -> Vec<String> {
355 const AUTO_GENERATED: &[&str] = &["code-mode://instructions", "code-mode://policies"];
356
357 let prompt = prompts.iter().find(|p| p.name == CODE_MODE_PROMPT_NAME);
358 let Some(prompt) = prompt else {
359 return vec![];
360 };
361
362 prompt
363 .include_resources
364 .iter()
365 .filter(|uri| !AUTO_GENERATED.contains(&uri.as_str()))
366 .filter_map(|uri| {
367 resources
368 .iter()
369 .find(|r| r.uri == *uri)
370 .and_then(|r| r.content.clone())
371 })
372 .filter(|c| !c.is_empty())
373 .collect()
374}
375
376/// Surface the toolkit's [`ToolkitError`] for consistency with other modules
377/// (currently unused inside the module — kept available for future API
378/// extensions that need to surface prompt-resolution failures).
379#[allow(dead_code)]
380fn _ensure_error_path_kept() -> Option<ToolkitError> {
381 None
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use crate::resources::ResourceConfig;
388 use pmcp::types::Content;
389 use pmcp::RequestHandlerExtra;
390
391 fn mk_extra() -> RequestHandlerExtra {
392 RequestHandlerExtra::default()
393 }
394
395 #[test]
396 fn prompt_config_to_info() {
397 let config = PromptConfig {
398 name: "test-prompt".to_string(),
399 description: "A test prompt".to_string(),
400 include_resources: vec!["docs://test".to_string()],
401 };
402
403 let info = config.to_prompt_info();
404 assert_eq!(info.name, "test-prompt");
405 assert_eq!(info.description, Some("A test prompt".to_string()));
406 assert!(info.arguments.is_none());
407 }
408
409 /// Requirement: `handle()` with all required args present returns
410 /// `Ok(GetPromptResult)` with the user-role body message.
411 #[tokio::test]
412 async fn handle_with_all_required_args_succeeds() {
413 let handler = StaticPromptHandler::new(
414 "needs-foo",
415 Some("requires foo"),
416 vec![PromptArgument::new("foo").required()],
417 "Hello {{foo}}",
418 );
419
420 let args = HashMap::from([("foo".to_string(), "world".to_string())]);
421 let result = handler.handle(args, mk_extra()).await.unwrap();
422
423 assert_eq!(result.messages.len(), 1);
424 assert_eq!(result.description.as_deref(), Some("requires foo"));
425 match &result.messages[0].content {
426 Content::Text { text } => assert_eq!(text, "Hello {{foo}}"),
427 other => panic!("expected text content, got {:?}", other),
428 }
429 }
430
431 /// Requirement: `handle()` returns `pmcp::Error::validation(...)` when a
432 /// required argument is absent, and the error message names the missing
433 /// argument.
434 #[tokio::test]
435 async fn handle_missing_required_arg_returns_validation_err() {
436 let handler = StaticPromptHandler::new(
437 "needs-foo",
438 Some("requires foo"),
439 vec![PromptArgument::new("foo").required()],
440 "Hello {{foo}}",
441 );
442
443 let result = handler.handle(HashMap::new(), mk_extra()).await;
444 let err = result.expect_err("expected validation error");
445 let msg = err.to_string();
446 assert!(
447 msg.contains("foo"),
448 "error message should mention the missing argument 'foo': {msg}",
449 );
450 assert!(
451 msg.to_lowercase().contains("missing") || msg.to_lowercase().contains("required"),
452 "error message should indicate the missing-required-arg path: {msg}",
453 );
454 }
455
456 /// Requirement: `metadata()` returns `Some(PromptInfo)` built via the
457 /// PromptInfo constructor (NOT struct-literal), with description and
458 /// arguments populated.
459 #[tokio::test]
460 async fn metadata_returns_some_promptinfo_with_description_and_args() {
461 let handler = StaticPromptHandler::new(
462 "with-meta",
463 Some("a described prompt"),
464 vec![
465 PromptArgument::new("a").required(),
466 PromptArgument::new("b"),
467 ],
468 "body",
469 );
470
471 let info = handler.metadata().expect("metadata should return Some");
472 assert_eq!(info.name, "with-meta");
473 assert_eq!(info.description.as_deref(), Some("a described prompt"));
474 let args = info.arguments.expect("arguments should be populated");
475 assert_eq!(args.len(), 2);
476 assert_eq!(args[0].name, "a");
477 assert!(args[0].required);
478 assert_eq!(args[1].name, "b");
479 assert!(!args[1].required);
480 }
481
482 #[test]
483 fn metadata_with_no_arguments_omits_arguments_field() {
484 let handler = StaticPromptHandler::new("plain", Some("d"), vec![], "body");
485 let info = handler.metadata().unwrap();
486 assert!(info.arguments.is_none());
487 }
488
489 #[tokio::test]
490 async fn from_configs_resolves_resource_bodies_deterministically() {
491 let resource_configs = vec![ResourceConfig {
492 uri: "docs://test".to_string(),
493 name: "Test Resource".to_string(),
494 description: None,
495 mime_type: "text/plain".to_string(),
496 content: Some("Hello from resource".to_string()),
497 content_file: None,
498 meta: None,
499 }];
500 let resources =
501 crate::resources::StaticResourceHandler::from_configs(&resource_configs).unwrap();
502
503 let prompts = vec![
504 PromptConfig {
505 name: "p1".to_string(),
506 description: "first".to_string(),
507 include_resources: vec!["docs://test".to_string()],
508 },
509 PromptConfig {
510 name: "p2".to_string(),
511 description: "second".to_string(),
512 include_resources: vec![],
513 },
514 ];
515
516 let mut materialized = StaticPromptHandler::from_configs(&prompts, &resources);
517 assert_eq!(materialized.len(), 2);
518 assert_eq!(materialized[0].0, "p1");
519 assert_eq!(materialized[1].0, "p2");
520
521 // p1 resolved the resource body verbatim.
522 let (_, p1_handler) = materialized.remove(0);
523 let result = p1_handler.handle(HashMap::new(), mk_extra()).await.unwrap();
524 match &result.messages[0].content {
525 Content::Text { text } => assert_eq!(text, "Hello from resource"),
526 other => panic!("expected text, got {:?}", other),
527 }
528
529 // p2 had no resources → placeholder body.
530 let (_, p2_handler) = materialized.remove(0);
531 let result = p2_handler.handle(HashMap::new(), mk_extra()).await.unwrap();
532 match &result.messages[0].content {
533 Content::Text { text } => assert!(text.contains("p2")),
534 other => panic!("expected text, got {:?}", other),
535 }
536 }
537
538 #[test]
539 fn resolve_extra_prompt_content_filters_auto_generated() {
540 let prompts = vec![PromptConfig {
541 name: CODE_MODE_PROMPT_NAME.to_string(),
542 description: "code mode".to_string(),
543 include_resources: vec![
544 "code-mode://instructions".to_string(), // auto-generated, filtered
545 "docs://learnings".to_string(),
546 ],
547 }];
548 let resources = vec![
549 ResourceConfig {
550 uri: "code-mode://instructions".to_string(),
551 name: "auto".to_string(),
552 description: None,
553 mime_type: "text/markdown".to_string(),
554 content: Some("AUTO".to_string()),
555 content_file: None,
556 meta: None,
557 },
558 ResourceConfig {
559 uri: "docs://learnings".to_string(),
560 name: "learnings".to_string(),
561 description: None,
562 mime_type: "text/markdown".to_string(),
563 content: Some("LEARNED".to_string()),
564 content_file: None,
565 meta: None,
566 },
567 ];
568
569 let extras = resolve_extra_prompt_content(&prompts, &resources);
570 assert_eq!(extras, vec!["LEARNED".to_string()]);
571 }
572}