MCP Skill Integration
Load reusable prompts from services/skills with SkillService, pass them as AiMessage::system to the AI service, and combine voice and content skills.
On this page
Skills are reusable prompts loaded from disk that guide AI behavior. MCP servers can load skills to provide consistent, high-quality AI-powered tool implementations.
What Are Skills?
Skills are prompt templates that define:
- Voice — Writing style and tone
- Instructions — What the AI should do
- Constraints — Rules and limitations
- Output format — Expected response structure
Skills live on disk under services/skills/<id>/, one directory per skill containing a config.yaml and a SKILL.md instruction body. The SkillService reads them directly from disk at load time.
Setting Up SkillService
Initialize the SkillService in your server:
use std::sync::Arc;
use systemprompt::agent::services::SkillService;
#[derive(Clone)]
pub struct MyServer {
service_id: McpServerId,
skill_loader: Arc<SkillService>,
}
impl MyServer {
pub fn new(service_id: McpServerId) -> Result<Self> {
let skill_loader = Arc::new(SkillService::new()?);
Ok(Self {
service_id,
skill_loader,
})
}
}
SkillService::new() resolves the skills root from the active profile; no database pool is required.
Loading Skills
Load a skill by its ID:
use systemprompt::agent::services::SkillService;
use systemprompt::identifiers::SkillId;
use systemprompt::models::execution::context::RequestContext;
pub async fn handle(
skill_loader: &SkillService,
ctx: &RequestContext,
skill_id: &SkillId,
) -> Result<CallToolResult, McpError> {
// Load skill content
let skill_content = skill_loader
.load_skill(skill_id, ctx)
.await
.map_err(|e| {
McpError::internal_error(
format!("Failed to load skill '{skill_id}': {e}"),
None,
)
})?;
// Use skill_content as system prompt
let messages = vec![
AiMessage::system(&skill_content),
AiMessage::user(&user_prompt),
];
// Call AI service...
}
Skill ID Validation
Validate that the tool is called with the correct skill:
let skill_id = args
.get("skill_id")
.and_then(|v| v.as_str())
.ok_or_else(|| {
McpError::invalid_params("Missing required parameter: skill_id", None)
})?;
// Validate specific skill
if skill_id != "research_blog" {
return Err(McpError::invalid_params(
format!("skill_id must be 'research_blog', got '{skill_id}'"),
None,
));
}
// Or validate against allowed list
let allowed_skills = ["blog_writing", "technical_content_writing"];
if !allowed_skills.contains(&skill_id) {
return Err(McpError::invalid_params(
format!(
"Invalid skill_id: '{skill_id}'. Must be one of: {}",
allowed_skills.join(", ")
),
None,
));
}
Using Skills with AI Service
Basic Generation
use systemprompt::ai::{AiMessage, AiRequest, AiService};
use systemprompt::models::execution::context::RequestContext;
pub async fn generate_content(
ai_service: &Arc<AiService>,
ctx: &RequestContext,
skill_content: &str,
user_prompt: &str,
) -> Result<String, McpError> {
let messages = vec![
AiMessage::system(skill_content),
AiMessage::user(user_prompt),
];
let request = AiRequest::builder(
messages,
ai_service.default_provider(),
ai_service.default_model(),
8192,
ctx.clone(),
)
.build();
let response = ai_service
.generate(&request)
.await
.map_err(|e| McpError::internal_error(format!("AI error: {e}"), None))?;
Ok(response.content)
}
Google Search Grounding
For research tools that need web search:
use systemprompt::ai::{AiMessage, AiService, GoogleSearchParams};
pub async fn research_with_search(
ai_service: &Arc<AiService>,
skill_content: &str,
topic: &str,
) -> Result<SearchGroundedResponse, McpError> {
let messages = vec![
AiMessage::system(skill_content),
AiMessage::user(&format!("Research the topic: {topic}")),
];
let params = GoogleSearchParams {
messages,
sampling: None,
max_output_tokens: 8192,
model: Some("gemini-2.0-flash"),
urls: None,
response_schema: None,
};
let response = ai_service
.generate_with_google_search(params)
.await
.map_err(|e| McpError::internal_error(format!("Search error: {e}"), None))?;
// response.content - Generated text
// response.sources - Vec<WebSource> with title, uri, relevance
// response.web_search_queries - Queries used
Ok(response)
}
Structured Output
Generate JSON that matches a schema:
use systemprompt::ai::{AiMessage, AiRequest, AiService, StructuredOutputOptions};
use systemprompt::models::execution::context::RequestContext;
use serde::Deserialize;
#[derive(Deserialize)]
struct BlogOutline {
title: String,
sections: Vec<Section>,
}
#[derive(Deserialize)]
struct Section {
heading: String,
key_points: Vec<String>,
}
pub async fn generate_outline(
ai_service: &Arc<AiService>,
ctx: &RequestContext,
skill_content: &str,
topic: &str,
) -> Result<BlogOutline, McpError> {
let schema = serde_json::json!({
"type": "object",
"properties": {
"title": {"type": "string"},
"sections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"heading": {"type": "string"},
"key_points": {
"type": "array",
"items": {"type": "string"}
}
},
"required": ["heading", "key_points"]
}
}
},
"required": ["title", "sections"]
});
let request = AiRequest::builder(
vec![
AiMessage::system(skill_content),
AiMessage::user(&format!("Create an outline for: {topic}")),
],
ai_service.default_provider(),
ai_service.default_model(),
8192,
ctx.clone(),
)
.with_structured_output(StructuredOutputOptions::with_schema(schema))
.build();
let response = ai_service
.generate(&request)
.await
.map_err(|e| McpError::internal_error(format!("AI error: {e}"), None))?;
let outline: BlogOutline = serde_json::from_str(&response.content)
.map_err(|e| McpError::internal_error(format!("Parse error: {e}"), None))?;
Ok(outline)
}
Combining Skills
Voice + Content Skills
Combine a voice skill with a content skill:
pub async fn load_combined_skills(
skill_loader: &SkillService,
ctx: &RequestContext,
voice_skill_id: &SkillId,
content_skill_id: &SkillId,
) -> Result<String, McpError> {
let voice_skill = skill_loader
.load_skill(voice_skill_id, ctx)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let content_skill = skill_loader
.load_skill(content_skill_id, ctx)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
// Combine with separator
Ok(format!("{voice_skill}\n\n---\n\n{content_skill}"))
}
// Usage
let combined = load_combined_skills(
skill_loader,
&ctx,
&SkillId::new("edwards_voice"), // Voice/style skill
&SkillId::new("linkedin_post_writing") // Platform skill
).await?;
Platform-Specific Skills
Map platforms to skill IDs:
pub fn get_skill_for_platform(platform: &str) -> Result<&'static str, McpError> {
match platform {
"linkedin" => Ok("linkedin_post_writing"),
"twitter" => Ok("twitter_post_writing"),
"reddit" => Ok("reddit_post_writing"),
"medium" => Ok("medium_article_writing"),
_ => Err(McpError::invalid_params(
format!("Unsupported platform: {platform}"),
None,
)),
}
}
// Usage
let platform = args.get("platform").and_then(|v| v.as_str()).unwrap();
let skill_id = SkillId::new(get_skill_for_platform(platform)?);
let skill_content = skill_loader.load_skill(&skill_id, &ctx).await?;
Skill-Driven Pipelines
Research → Create Pipeline
// Step 1: Research with research_blog skill
pub async fn research_topic(
ai_service: &Arc<AiService>,
skill_loader: &SkillService,
ctx: &RequestContext,
topic: &str,
) -> Result<ResearchResult, McpError> {
let skill = skill_loader
.load_skill(&SkillId::new("research_blog"), ctx)
.await?;
let params = GoogleSearchParams {
messages: vec![
AiMessage::system(&skill),
AiMessage::user(&format!("Research: {topic}")),
],
// ...
};
let response = ai_service.generate_with_google_search(params).await?;
Ok(ResearchResult {
summary: response.content,
sources: response.sources,
})
}
// Step 2: Create content with blog_writing skill
pub async fn create_blog_post(
ai_service: &Arc<AiService>,
skill_loader: &SkillService,
ctx: &RequestContext,
research: &ResearchResult,
instructions: &str,
) -> Result<BlogPost, McpError> {
let skill = skill_loader
.load_skill(&SkillId::new("blog_writing"), ctx)
.await?;
let prompt = format!(
"Create a blog post based on this research:\n\n\
{}\n\n\
Sources:\n{}\n\n\
Instructions: {}",
research.summary,
format_sources(&research.sources),
instructions
);
// Generate structured blog post...
}
Error Handling
Handle skill loading failures gracefully:
let skill_content = match skill_loader.load_skill(skill_id, &ctx).await {
Ok(content) => content,
Err(e) => {
tracing::error!(
skill_id = %skill_id,
error = %e,
"Failed to load skill"
);
return Err(McpError::internal_error(
format!("Skill '{skill_id}' not found or failed to load: {e}"),
None,
));
}
};
Skill Attribution
Track which skill was used in artifacts:
let metadata = ArtifactMetadata::new(
"blog_artifact".to_string(),
context_id.clone(),
task_id.clone(),
)
.with_tool_name("create_blog_post".to_string())
.with_skill(SkillId::new(skill_id), "Blog Writing".to_string());
Include in response:
Ok(CallToolResult {
content: vec![Content::text("...")],
structured_content: Some(json!({
"content_id": content_id,
"skill_id": skill_id,
"skill_name": "Blog Writing",
"status": "completed"
})),
is_error: Some(false),
meta: None,
})
Skill File Format
Each skill is a directory under services/skills/ containing a config.yaml and a SKILL.md instruction body:
# services/skills/blog_writing/config.yaml
id: blog_writing
name: Blog Writing
description: "Write engaging blog posts"
enabled: true
file: SKILL.md
category: content
tags:
- content
- writing
<!-- services/skills/blog_writing/SKILL.md -->
You are a skilled blog writer. Your task is to create engaging,
well-structured blog posts that inform and captivate readers.
## Guidelines
- Write in a conversational but professional tone
- Use clear headings and subheadings
- Include practical examples
- End with actionable takeaways
## Output Format
Return the blog post in markdown format with proper headings.
Skills are ingested from disk at startup. Inspect them with the CLI:
systemprompt core skills list
systemprompt core skills show <skill-id>
Quick Reference
| Task | Code |
|---|---|
| Initialize service | SkillService::new() |
| Load skill | skill_loader.load_skill(&skill_id, &ctx).await |
| Use in prompt | AiMessage::system(&skill_content) |
| Validate skill ID | Check against allowed list |
| Combine skills | Join with "\n\n---\n\n" separator |
| Track in artifact | .with_skill(id, name) |