use anyhow::Result;
use base64::{engine::general_purpose, Engine as _};
use chrono::{DateTime, Utc};
use rmcp::handler::server::router::prompt::PromptRouter;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{
CallToolResult, Content, ErrorCode, GetPromptRequestParam, GetPromptResult, Implementation,
ListPromptsResult, PaginatedRequestParam, PromptMessage, PromptMessageRole, ProtocolVersion,
ServerCapabilities, ServerInfo,
};
use rmcp::prompt;
use rmcp::prompt_handler;
use rmcp::prompt_router;
use rmcp::service::RequestContext;
use rmcp::tool;
use rmcp::tool_handler;
use rmcp::tool_router;
use rmcp::transport::stdio;
use rmcp::ErrorData as McpError;
use rmcp::{schemars, RoleServer, ServerHandler, ServiceExt};
use crate::config::Config;
use crate::draft::Draft;
use crate::draft_push::validate_draft_id;
use crate::publish;
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct PublishPostArgs {
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub categories: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct CreateDraftArgs {
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct PublishBackdateArgs {
#[schemars(regex(pattern = r"^[a-zA-Z0-9_-]+$"))]
pub draft_id: String,
pub date: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct DeletePostArgs {
#[schemars(url)]
pub url: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ListPostsArgs {
#[serde(default = "default_limit")]
pub limit: usize,
#[serde(default)]
pub offset: usize,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ViewDraftArgs {
#[schemars(regex(pattern = r"^[a-zA-Z0-9_-]+$"))]
pub draft_id: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ListMediaArgs {
#[serde(default = "default_media_limit")]
pub limit: usize,
#[serde(default)]
pub offset: usize,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct UploadMediaArgs {
#[serde(skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_data: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alt_text: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct PushDraftArgs {
#[schemars(regex(pattern = r"^[a-zA-Z0-9_-]+$"))]
pub draft_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub backdate: Option<String>,
}
fn default_limit() -> usize {
10
}
fn default_media_limit() -> usize {
20
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct QuickNotePromptArgs {
#[schemars(length(min = 1, max = 200))]
pub topic: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct PhotoPostPromptArgs {
#[schemars(length(min = 1, max = 200))]
pub subject: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ArticleDraftPromptArgs {
#[schemars(length(min = 1, max = 200))]
pub topic: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(length(max = 500))]
pub key_points: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct BackdateMemoryPromptArgs {
#[schemars(length(min = 1, max = 300))]
pub memory: String,
#[schemars(length(min = 1, max = 100))]
pub when: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct CategorizedPostPromptArgs {
#[schemars(length(min = 1, max = 200))]
pub topic: String,
#[schemars(length(min = 1, max = 100))]
pub categories: String,
}
#[derive(Clone)]
pub struct MicropubMcp {
tool_router: ToolRouter<MicropubMcp>,
prompt_router: PromptRouter<MicropubMcp>,
}
impl MicropubMcp {
pub fn new() -> Result<Self> {
Ok(Self {
tool_router: Self::tool_router(),
prompt_router: Self::prompt_router(),
})
}
}
#[tool_router]
impl MicropubMcp {
#[tool(
description = "Create and publish a micropub post with optional title and categories. Automatically detects and uploads local image files (e.g.,  or <img src='/path/image.png'>) and replaces them with permanent URLs before publishing."
)]
async fn publish_post(
&self,
Parameters(args): Parameters<PublishPostArgs>,
) -> Result<CallToolResult, McpError> {
if args.content.trim().is_empty() {
return Err(McpError::invalid_params(
"Content cannot be empty".to_string(),
None,
));
}
let mut draft = Draft::new(uuid::Uuid::new_v4().to_string());
draft.content = args.content;
draft.metadata.name = args.title;
if let Some(cats) = args.categories {
draft.metadata.category = cats.split(',').map(|s| s.trim().to_string()).collect();
}
let draft_path = draft.save().map_err(|e| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to save draft: {}", e),
None,
)
})?;
let draft_path_str = draft_path.to_str().ok_or_else(|| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
"Draft path contains invalid UTF-8".to_string(),
None,
)
})?;
let uploads = publish::cmd_publish(draft_path_str, None)
.await
.map_err(|e| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to publish: {}", e),
None,
)
})?;
let mut message = String::from("Post published successfully!");
if !uploads.is_empty() {
message.push_str("\n\nUploaded media:");
for (filename, url) in uploads {
message.push_str(&format!("\n- {} -> {}", filename, url));
}
}
Ok(CallToolResult::success(vec![Content::text(message)]))
}
#[tool(description = "Create a draft micropub post for later editing and publishing")]
async fn create_draft(
&self,
Parameters(args): Parameters<CreateDraftArgs>,
) -> Result<CallToolResult, McpError> {
if args.content.trim().is_empty() {
return Err(McpError::invalid_params(
"Content cannot be empty".to_string(),
None,
));
}
let mut draft = Draft::new(uuid::Uuid::new_v4().to_string());
draft.content = args.content;
draft.metadata.name = args.title;
draft.save().map_err(|e| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to create draft: {}", e),
None,
)
})?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Draft created with ID: {}",
draft.id
))]))
}
#[tool(description = "List all draft micropub posts")]
async fn list_drafts(&self) -> Result<CallToolResult, McpError> {
let draft_ids = Draft::list_all().map_err(|e| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to list drafts: {}", e),
None,
)
})?;
if draft_ids.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"No drafts found.",
)]));
}
let mut output = String::from("Drafts:\n");
for id in draft_ids {
if let Ok(draft) = Draft::load(&id) {
let title = draft
.metadata
.name
.unwrap_or_else(|| "[untitled]".to_string());
output.push_str(&format!("- {} ({})\n", title, id));
}
}
Ok(CallToolResult::success(vec![Content::text(output)]))
}
#[tool(description = "Publish a draft post with a specific past date (ISO 8601 format)")]
async fn publish_backdate(
&self,
Parameters(args): Parameters<PublishBackdateArgs>,
) -> Result<CallToolResult, McpError> {
validate_draft_id(&args.draft_id)
.map_err(|e| McpError::invalid_params(format!("Invalid draft ID: {}", e), None))?;
let parsed_date = DateTime::parse_from_rfc3339(&args.date)
.map_err(|e| {
McpError::invalid_params(
format!(
"Invalid date format: {}. Use ISO 8601 like 2024-01-15T10:30:00Z",
e
),
None,
)
})?
.with_timezone(&Utc);
let draft_path = crate::config::get_drafts_dir()
.map_err(|e| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to get drafts dir: {}", e),
None,
)
})?
.join(format!("{}.md", args.draft_id));
if !draft_path.exists() {
return Err(McpError::invalid_params(
format!("Draft not found: {}", args.draft_id),
None,
));
}
let draft_path_str = draft_path.to_str().ok_or_else(|| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
"Draft path contains invalid UTF-8".to_string(),
None,
)
})?;
let uploads = publish::cmd_publish(draft_path_str, Some(parsed_date))
.await
.map_err(|e| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to publish: {}", e),
None,
)
})?;
let mut message = format!("Post published with backdated timestamp: {}", args.date);
if !uploads.is_empty() {
message.push_str("\n\nUploaded media:");
for (filename, url) in uploads {
message.push_str(&format!("\n- {} -> {}", filename, url));
}
}
Ok(CallToolResult::success(vec![Content::text(message)]))
}
#[tool(description = "Delete a published micropub post by URL")]
async fn delete_post(
&self,
Parameters(args): Parameters<DeletePostArgs>,
) -> Result<CallToolResult, McpError> {
if args.url.is_empty() {
return Err(McpError::invalid_params(
"URL cannot be empty".to_string(),
None,
));
}
crate::operations::cmd_delete(&args.url)
.await
.map_err(|e| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to delete post: {}", e),
None,
)
})?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Post deleted: {}",
args.url
))]))
}
#[tool(description = "Check which micropub account is currently authenticated")]
async fn whoami(&self) -> Result<CallToolResult, McpError> {
let config = Config::load().map_err(|e| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to load config: {}", e),
None,
)
})?;
let profile_name = &config.default_profile;
if profile_name.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"No profile configured. Run 'micropub auth <domain>' first.",
)]));
}
let profile = config.get_profile(profile_name).ok_or_else(|| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
"Profile not found".to_string(),
None,
)
})?;
let output = format!(
"Authenticated as:\n Profile: {}\n Domain: {}\n Micropub: {}",
profile_name,
profile.domain,
profile
.micropub_endpoint
.as_deref()
.unwrap_or("(not configured)")
);
Ok(CallToolResult::success(vec![Content::text(output)]))
}
#[tool(description = "List published micropub posts with pagination")]
async fn list_posts(
&self,
Parameters(args): Parameters<ListPostsArgs>,
) -> Result<CallToolResult, McpError> {
let posts = crate::operations::fetch_posts(args.limit, args.offset)
.await
.map_err(|e| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to fetch posts: {}", e),
None,
)
})?;
if posts.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"No posts found.",
)]));
}
let mut output = String::from("Posts:\n\n");
for post in posts {
let title = post.name.unwrap_or_else(|| "[untitled]".to_string());
output.push_str(&format!("- {} ({})\n", title, post.url));
output.push_str(&format!(" Published: {}\n", post.published));
if !post.categories.is_empty() {
output.push_str(&format!(" Categories: {}\n", post.categories.join(", ")));
}
if !post.content.is_empty() {
let preview = if post.content.len() > 100 {
format!("{}...", &post.content[..100])
} else {
post.content.clone()
};
output.push_str(&format!(" Preview: {}\n", preview));
}
output.push('\n');
}
Ok(CallToolResult::success(vec![Content::text(output)]))
}
#[tool(description = "View the content of a specific draft")]
async fn view_draft(
&self,
Parameters(args): Parameters<ViewDraftArgs>,
) -> Result<CallToolResult, McpError> {
validate_draft_id(&args.draft_id)
.map_err(|e| McpError::invalid_params(format!("Invalid draft ID: {}", e), None))?;
let draft = Draft::load(&args.draft_id)
.map_err(|e| McpError::invalid_params(format!("Failed to load draft: {}", e), None))?;
let mut output = String::new();
output.push_str(&format!("Draft: {}\n\n", args.draft_id));
if let Some(ref title) = draft.metadata.name {
output.push_str(&format!("Title: {}\n", title));
}
if !draft.metadata.category.is_empty() {
output.push_str(&format!(
"Categories: {}\n",
draft.metadata.category.join(", ")
));
}
output.push_str(&format!("\nContent:\n{}", draft.content));
Ok(CallToolResult::success(vec![Content::text(output)]))
}
#[tool(description = "List uploaded media files with pagination")]
async fn list_media(
&self,
Parameters(args): Parameters<ListMediaArgs>,
) -> Result<CallToolResult, McpError> {
let media_items = crate::operations::fetch_media(args.limit, args.offset)
.await
.map_err(|e| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to fetch media: {}", e),
None,
)
})?;
if media_items.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"No media files found.",
)]));
}
let mut output = String::from("Media files:\n\n");
for item in media_items {
output.push_str(&format!("- {}\n", item.url));
if let Some(ref name) = item.name {
output.push_str(&format!(" Name: {}\n", name));
}
output.push_str(&format!(" Uploaded: {}\n\n", item.uploaded));
}
Ok(CallToolResult::success(vec![Content::text(output)]))
}
#[tool(
description = "Upload an image or media file to your micropub site. Supports local file paths (e.g., ~/Pictures/photo.jpg) or base64-encoded data. Returns URL and markdown snippet for use in posts."
)]
async fn upload_media(
&self,
Parameters(args): Parameters<UploadMediaArgs>,
) -> Result<CallToolResult, McpError> {
let has_path = args.file_path.is_some();
let has_data = args.file_data.is_some();
let has_filename = args.filename.is_some();
if !has_path && !has_data {
return Err(McpError::invalid_params(
"Must provide either file_path OR file_data".to_string(),
None,
));
}
if has_path && has_data {
return Err(McpError::invalid_params(
"Cannot provide both file_path and file_data".to_string(),
None,
));
}
if has_data && !has_filename {
return Err(McpError::invalid_params(
"filename is required when using file_data".to_string(),
None,
));
}
let config = Config::load().map_err(|e| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to load config: {}", e),
None,
)
})?;
let profile_name = &config.default_profile;
if profile_name.is_empty() {
return Err(McpError::new(
ErrorCode::INTERNAL_ERROR,
"No profile configured. Run 'micropub auth <domain>' first.".to_string(),
None,
));
}
let profile = config.get_profile(profile_name).ok_or_else(|| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
"Profile not found".to_string(),
None,
)
})?;
let media_endpoint = profile.media_endpoint.as_ref().ok_or_else(|| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
"No media endpoint configured. Server may not support media uploads.".to_string(),
None,
)
})?;
let token = crate::config::load_token(profile_name).map_err(|e| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to load token: {}", e),
None,
)
})?;
let (url, filename_str, mime_type) = if let Some(file_path) = args.file_path {
let resolved_path = crate::media::resolve_path(&file_path, None)
.map_err(|e| McpError::invalid_params(format!("Invalid file path: {}", e), None))?;
let filename = resolved_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| McpError::invalid_params("Invalid filename".to_string(), None))?
.to_string();
let mime = mime_guess::from_path(&resolved_path).first_or_octet_stream();
let url = crate::media::upload_file(media_endpoint, &token, &resolved_path)
.await
.map_err(|e| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("Upload failed: {}", e),
None,
)
})?;
(url, filename, mime.to_string())
} else if let Some(file_data) = args.file_data {
let filename = args.filename.unwrap();
let decoded = general_purpose::STANDARD.decode(&file_data).map_err(|e| {
McpError::invalid_params(format!("Invalid base64 data: {}", e), None)
})?;
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join(&filename);
std::fs::write(&temp_path, decoded).map_err(|e| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to write temp file: {}", e),
None,
)
})?;
let mime = mime_guess::from_path(&temp_path).first_or_octet_stream();
let url = crate::media::upload_file(media_endpoint, &token, &temp_path)
.await
.map_err(|e| {
let _ = std::fs::remove_file(&temp_path);
McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("Upload failed: {}", e),
None,
)
})?;
let _ = std::fs::remove_file(&temp_path);
(url, filename, mime.to_string())
} else {
unreachable!("Validation ensures file_path or file_data is present");
};
let alt_text = args.alt_text.unwrap_or_default();
let markdown = if alt_text.is_empty() {
format!("", url)
} else {
format!("", alt_text, url)
};
let response = serde_json::json!({
"url": url,
"filename": filename_str,
"mime_type": mime_type,
"markdown": markdown
});
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&response).unwrap_or_else(|_| response.to_string()),
)]))
}
#[tool(
description = "Push a local draft to the server as a server-side draft (post-status: draft). Uploads any media files and returns the server URL. Can be used to create new server drafts or update existing ones. Supports backdating."
)]
async fn push_draft(
&self,
Parameters(args): Parameters<PushDraftArgs>,
) -> Result<CallToolResult, McpError> {
validate_draft_id(&args.draft_id)
.map_err(|e| McpError::invalid_params(format!("Invalid draft ID: {}", e), None))?;
let backdate_parsed = if let Some(date_str) = args.backdate {
let parsed = DateTime::parse_from_rfc3339(&date_str)
.map_err(|e| {
McpError::invalid_params(
format!(
"Invalid backdate format: {}. Use ISO 8601 like 2024-01-15T10:30:00Z",
e
),
None,
)
})?
.with_timezone(&Utc);
Some(parsed)
} else {
None
};
let result = crate::draft_push::cmd_push_draft(&args.draft_id, backdate_parsed)
.await
.map_err(|e| {
McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to push draft: {}", e),
None,
)
})?;
let response = serde_json::json!({
"url": result.url,
"is_update": result.is_update,
"status": "server-draft",
"uploaded_media": result.uploads.iter().map(|(filename, url)| {
serde_json::json!({
"filename": filename,
"url": url
})
}).collect::<Vec<_>>()
});
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&response).unwrap_or_else(|_| response.to_string()),
)]))
}
}
#[prompt_router]
impl MicropubMcp {
#[prompt(
name = "quick-note",
description = "Post a quick note or thought to your micropub site"
)]
async fn quick_note(
&self,
Parameters(args): Parameters<QuickNotePromptArgs>,
) -> Result<GetPromptResult, McpError> {
let topic = args.topic.trim();
if topic.is_empty() {
return Err(McpError::invalid_params(
"Topic cannot be empty".to_string(),
None,
));
}
Ok(GetPromptResult {
description: Some("Quick note posting workflow".to_string()),
messages: vec![
PromptMessage::new_text(
PromptMessageRole::User,
format!("I want to post a quick note about: {}", topic),
),
PromptMessage::new_text(
PromptMessageRole::Assistant,
format!(
"I'll help you create a quick note about {}. What would you like to say?",
topic
),
),
],
})
}
#[prompt(
name = "photo-post",
description = "Create a photo post with caption for your micropub site"
)]
async fn photo_post(
&self,
Parameters(args): Parameters<PhotoPostPromptArgs>,
) -> Result<GetPromptResult, McpError> {
let subject = args.subject.trim();
if subject.is_empty() {
return Err(McpError::invalid_params(
"Subject cannot be empty".to_string(),
None,
));
}
Ok(GetPromptResult {
description: Some("Photo post workflow".to_string()),
messages: vec![
PromptMessage::new_text(
PromptMessageRole::User,
format!("I want to post a photo about: {}", subject),
),
PromptMessage::new_text(
PromptMessageRole::Assistant,
format!(
"I'll help you create a photo post about {}. You can:\n\
1. Upload the image first with 'upload_media' tool, then use the URL\n\
2. Reference a local file (e.g., ~/Pictures/photo.jpg) and I'll auto-upload when publishing\n\n\
Please provide the photo path and a caption.",
subject
),
),
],
})
}
#[prompt(
name = "article-draft",
description = "Create a longer article draft for later editing and publishing"
)]
async fn article_draft(
&self,
Parameters(args): Parameters<ArticleDraftPromptArgs>,
) -> Result<GetPromptResult, McpError> {
let topic = args.topic.trim();
if topic.is_empty() {
return Err(McpError::invalid_params(
"Topic cannot be empty".to_string(),
None,
));
}
let key_points = args.key_points.as_ref().map(|p| p.trim());
if let Some(points) = key_points {
if points.is_empty() {
return Err(McpError::invalid_params(
"Key points cannot be empty if provided".to_string(),
None,
));
}
}
let key_points_text = if let Some(points) = key_points {
format!("\n\nKey points to cover:\n{}", points)
} else {
String::new()
};
Ok(GetPromptResult {
description: Some("Article draft creation workflow".to_string()),
messages: vec![
PromptMessage::new_text(
PromptMessageRole::User,
format!(
"I want to write an article about: {}{}",
topic, key_points_text
),
),
PromptMessage::new_text(
PromptMessageRole::Assistant,
format!(
"I'll help you draft an article about {}. Let's start with:\n\
1. A compelling title\n\
2. An introduction that hooks the reader\n\
3. Main body sections{}\n\
4. A conclusion\n\n\
This will be saved as a draft for you to edit before publishing.",
topic,
if key_points.is_some() {
" covering your key points"
} else {
""
}
),
),
],
})
}
#[prompt(
name = "backdate-memory",
description = "Record a memory or past event with its original date"
)]
async fn backdate_memory(
&self,
Parameters(args): Parameters<BackdateMemoryPromptArgs>,
) -> Result<GetPromptResult, McpError> {
let memory = args.memory.trim();
if memory.is_empty() {
return Err(McpError::invalid_params(
"Memory cannot be empty".to_string(),
None,
));
}
let when = args.when.trim();
if when.is_empty() {
return Err(McpError::invalid_params(
"When cannot be empty".to_string(),
None,
));
}
Ok(GetPromptResult {
description: Some("Backdated memory recording workflow".to_string()),
messages: vec![
PromptMessage::new_text(
PromptMessageRole::User,
format!("I want to record this memory from {}: {}", when, memory),
),
PromptMessage::new_text(
PromptMessageRole::Assistant,
format!(
"I'll help you record this memory from {}. Let's:\n\
1. Write out the full memory in detail\n\
2. Convert '{}' to a specific date (ISO 8601 format)\n\
3. Save it as a draft\n\
4. Publish it with the backdated timestamp\n\n\
Tell me more about what happened.",
when, when
),
),
],
})
}
#[prompt(
name = "categorized-post",
description = "Create a post with specific categories for organization"
)]
async fn categorized_post(
&self,
Parameters(args): Parameters<CategorizedPostPromptArgs>,
) -> Result<GetPromptResult, McpError> {
let topic = args.topic.trim();
if topic.is_empty() {
return Err(McpError::invalid_params(
"Topic cannot be empty".to_string(),
None,
));
}
let categories = args.categories.trim();
if categories.is_empty() {
return Err(McpError::invalid_params(
"Categories cannot be empty".to_string(),
None,
));
}
Ok(GetPromptResult {
description: Some("Categorized post workflow".to_string()),
messages: vec![
PromptMessage::new_text(
PromptMessageRole::User,
format!(
"I want to post about {} in categories: {}",
topic, categories
),
),
PromptMessage::new_text(
PromptMessageRole::Assistant,
format!(
"I'll help you create a post about {} with categories: {}.\n\n\
What would you like to say? I'll make sure to tag it appropriately.",
topic, categories
),
),
],
})
}
#[prompt(
name = "new-post",
description = "General workflow for creating a new micropub post"
)]
async fn new_post(&self) -> GetPromptResult {
GetPromptResult {
description: Some("General micropub posting workflow".to_string()),
messages: vec![
PromptMessage::new_text(
PromptMessageRole::User,
"I want to create a new post".to_string(),
),
PromptMessage::new_text(
PromptMessageRole::Assistant,
"I'll help you create a new micropub post! What type of post would you like to make?\n\n\
- Quick note or thought\n\
- Photo with caption\n\
- Longer article (saved as draft)\n\
- Backdated memory\n\
- Categorized post\n\n\
Or just tell me what you want to post and I'll figure out the best format!".to_string(),
),
],
}
}
}
#[tool_handler]
#[prompt_handler(router = self.prompt_router)]
impl ServerHandler for MicropubMcp {
fn get_info(&self) -> ServerInfo {
ServerInfo {
protocol_version: ProtocolVersion::V_2024_11_05,
capabilities: ServerCapabilities::builder()
.enable_tools()
.enable_prompts()
.build(),
server_info: Implementation::from_build_env(),
instructions: Some(
"Micropub MCP server for posting and managing micropub content via AI assistants.\n\n\
IMAGE UPLOADS:\n\
- Use 'upload_media' tool to upload images explicitly (supports file paths or base64 data)\n\
- Or use 'publish_post' with local image paths (e.g., ) - they'll auto-upload\n\n\
SERVER-SIDE DRAFTS:\n\
- Use 'push_draft' tool to save drafts to server with post-status: draft\n\
- Drafts remain editable locally and can be re-pushed to update\n\
- Use 'publish_post' to change server draft to published status\n\
- Supports media upload and backdating when pushing drafts\n\n\
All uploads and draft operations require authentication via 'micropub auth <domain>' first."
.to_string(),
),
}
}
}
pub async fn run_server() -> Result<()> {
eprintln!("Starting Micropub MCP server...");
eprintln!("Ready to receive requests via stdio");
let service = MicropubMcp::new()?.serve(stdio()).await?;
service.waiting().await?;
Ok(())
}