Skip to main content

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}