This commit is contained in:
2025-06-06 02:14:35 +09:00
parent 02dd69840d
commit a9dca2fe38
33 changed files with 2141 additions and 9 deletions

6
src/mcp/mod.rs Normal file
View File

@ -0,0 +1,6 @@
pub mod server;
pub mod tools;
pub mod types;
pub use server::McpServer;
pub use types::*;

148
src/mcp/server.rs Normal file
View File

@ -0,0 +1,148 @@
use anyhow::Result;
use axum::{
extract::{Query, State},
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tower_http::cors::CorsLayer;
use crate::mcp::types::*;
use crate::mcp::tools::BlogTools;
#[derive(Clone)]
pub struct AppState {
blog_tools: Arc<BlogTools>,
}
pub struct McpServer {
app_state: AppState,
}
impl McpServer {
pub fn new(base_path: PathBuf) -> Self {
let blog_tools = Arc::new(BlogTools::new(base_path));
let app_state = AppState { blog_tools };
Self { app_state }
}
pub fn create_router(&self) -> Router {
Router::new()
.route("/", get(root_handler))
.route("/mcp/tools/list", get(list_tools))
.route("/mcp/tools/call", post(call_tool))
.route("/health", get(health_check))
.layer(CorsLayer::permissive())
.with_state(self.app_state.clone())
}
pub async fn serve(&self, port: u16) -> Result<()> {
let app = self.create_router();
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
println!("ai.log MCP Server listening on port {}", port);
axum::serve(listener, app).await?;
Ok(())
}
}
async fn root_handler() -> Json<Value> {
Json(json!({
"name": "ai.log MCP Server",
"version": "0.1.0",
"description": "AI-powered static blog generator with MCP integration",
"tools": ["create_blog_post", "list_blog_posts", "build_blog", "get_post_content"]
}))
}
async fn health_check() -> Json<Value> {
Json(json!({
"status": "healthy",
"timestamp": chrono::Utc::now().to_rfc3339()
}))
}
async fn list_tools() -> Json<Value> {
let tools = BlogTools::get_tools();
Json(json!({
"tools": tools
}))
}
async fn call_tool(
State(state): State<AppState>,
Json(request): Json<McpRequest>,
) -> Result<Json<McpResponse>, StatusCode> {
let tool_name = request.params
.as_ref()
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
.ok_or(StatusCode::BAD_REQUEST)?;
let arguments = request.params
.as_ref()
.and_then(|p| p.get("arguments"))
.cloned()
.unwrap_or(json!({}));
let result = match tool_name {
"create_blog_post" => {
let req: CreatePostRequest = serde_json::from_value(arguments)
.map_err(|_| StatusCode::BAD_REQUEST)?;
state.blog_tools.create_post(req).await
}
"list_blog_posts" => {
let req: ListPostsRequest = serde_json::from_value(arguments)
.map_err(|_| StatusCode::BAD_REQUEST)?;
state.blog_tools.list_posts(req).await
}
"build_blog" => {
let req: BuildRequest = serde_json::from_value(arguments)
.map_err(|_| StatusCode::BAD_REQUEST)?;
state.blog_tools.build_blog(req).await
}
"get_post_content" => {
let slug = arguments.get("slug")
.and_then(|v| v.as_str())
.ok_or(StatusCode::BAD_REQUEST)?;
state.blog_tools.get_post_content(slug).await
}
_ => {
return Ok(Json(McpResponse {
jsonrpc: "2.0".to_string(),
id: request.id,
result: None,
error: Some(McpError {
code: -32601,
message: format!("Method not found: {}", tool_name),
data: None,
}),
}));
}
};
match result {
Ok(tool_result) => Ok(Json(McpResponse {
jsonrpc: "2.0".to_string(),
id: request.id,
result: Some(serde_json::to_value(tool_result).unwrap()),
error: None,
})),
Err(e) => Ok(Json(McpResponse {
jsonrpc: "2.0".to_string(),
id: request.id,
result: None,
error: Some(McpError {
code: -32000,
message: e.to_string(),
data: None,
}),
})),
}
}

299
src/mcp/tools.rs Normal file
View File

@ -0,0 +1,299 @@
use anyhow::Result;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::fs;
use chrono::Local;
use crate::mcp::types::*;
use crate::generator::Generator;
use crate::config::Config;
pub struct BlogTools {
base_path: PathBuf,
}
impl BlogTools {
pub fn new(base_path: PathBuf) -> Self {
Self { base_path }
}
pub async fn create_post(&self, request: CreatePostRequest) -> Result<ToolResult> {
let posts_dir = self.base_path.join("content/posts");
// Generate slug if not provided
let slug = request.slug.unwrap_or_else(|| {
request.title
.chars()
.map(|c| if c.is_alphanumeric() || c == ' ' { c.to_lowercase().to_string() } else { "".to_string() })
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join("-")
});
let date = Local::now().format("%Y-%m-%d").to_string();
let filename = format!("{}-{}.md", date, slug);
let filepath = posts_dir.join(&filename);
// Create frontmatter
let mut frontmatter = format!(
"---\ntitle: {}\ndate: {}\n",
request.title, date
);
if let Some(tags) = request.tags {
if !tags.is_empty() {
frontmatter.push_str(&format!("tags: {:?}\n", tags));
}
}
frontmatter.push_str("---\n\n");
// Create full content
let full_content = format!("{}{}", frontmatter, request.content);
// Ensure directory exists
fs::create_dir_all(&posts_dir)?;
// Write file
fs::write(&filepath, full_content)?;
Ok(ToolResult {
content: vec![Content {
content_type: "text".to_string(),
text: format!("Post created successfully: {}", filename),
}],
is_error: None,
})
}
pub async fn list_posts(&self, request: ListPostsRequest) -> Result<ToolResult> {
let posts_dir = self.base_path.join("content/posts");
if !posts_dir.exists() {
return Ok(ToolResult {
content: vec![Content {
content_type: "text".to_string(),
text: "No posts directory found".to_string(),
}],
is_error: Some(true),
});
}
let mut posts = Vec::new();
for entry in fs::read_dir(&posts_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map_or(false, |ext| ext == "md") {
if let Ok(content) = fs::read_to_string(&path) {
// Parse frontmatter
if let Some((frontmatter_str, _)) = content.split_once("---\n") {
if let Some((_, frontmatter_content)) = frontmatter_str.split_once("---\n") {
// Simple YAML parsing for basic fields
let mut title = "Untitled".to_string();
let mut date = "Unknown".to_string();
let mut tags = Vec::new();
for line in frontmatter_content.lines() {
if let Some((key, value)) = line.split_once(':') {
let key = key.trim();
let value = value.trim();
match key {
"title" => title = value.to_string(),
"date" => date = value.to_string(),
"tags" => {
// Simple array parsing
if value.starts_with('[') && value.ends_with(']') {
let tags_str = &value[1..value.len()-1];
tags = tags_str.split(',')
.map(|s| s.trim().trim_matches('"').to_string())
.collect();
}
}
_ => {}
}
}
}
let slug = path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
posts.push(PostInfo {
title,
slug: slug.clone(),
date,
tags,
url: format!("/posts/{}.html", slug),
});
}
}
}
}
}
// Apply pagination
let offset = request.offset.unwrap_or(0);
let limit = request.limit.unwrap_or(10);
posts.sort_by(|a, b| b.date.cmp(&a.date));
let paginated_posts: Vec<_> = posts.into_iter()
.skip(offset)
.take(limit)
.collect();
let result = json!({
"posts": paginated_posts,
"total": paginated_posts.len()
});
Ok(ToolResult {
content: vec![Content {
content_type: "text".to_string(),
text: serde_json::to_string_pretty(&result)?,
}],
is_error: None,
})
}
pub async fn build_blog(&self, request: BuildRequest) -> Result<ToolResult> {
// Load configuration
let config = Config::load(&self.base_path)?;
// Create generator
let generator = Generator::new(self.base_path.clone(), config)?;
// Build the blog
generator.build().await?;
let message = if request.enable_ai.unwrap_or(false) {
"Blog built successfully with AI features enabled"
} else {
"Blog built successfully"
};
Ok(ToolResult {
content: vec![Content {
content_type: "text".to_string(),
text: message.to_string(),
}],
is_error: None,
})
}
pub async fn get_post_content(&self, slug: &str) -> Result<ToolResult> {
let posts_dir = self.base_path.join("content/posts");
// Find file by slug
for entry in fs::read_dir(&posts_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map_or(false, |ext| ext == "md") {
if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
if filename.contains(slug) {
let content = fs::read_to_string(&path)?;
return Ok(ToolResult {
content: vec![Content {
content_type: "text".to_string(),
text: content,
}],
is_error: None,
});
}
}
}
}
Ok(ToolResult {
content: vec![Content {
content_type: "text".to_string(),
text: format!("Post with slug '{}' not found", slug),
}],
is_error: Some(true),
})
}
pub fn get_tools() -> Vec<Tool> {
vec![
Tool {
name: "create_blog_post".to_string(),
description: "Create a new blog post with title, content, and optional tags".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The title of the blog post"
},
"content": {
"type": "string",
"description": "The content of the blog post in Markdown format"
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Optional tags for the blog post"
},
"slug": {
"type": "string",
"description": "Optional custom slug for the post URL"
}
},
"required": ["title", "content"]
}),
},
Tool {
name: "list_blog_posts".to_string(),
description: "List existing blog posts with pagination".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Maximum number of posts to return (default: 10)"
},
"offset": {
"type": "integer",
"description": "Number of posts to skip (default: 0)"
}
}
}),
},
Tool {
name: "build_blog".to_string(),
description: "Build the static blog with AI features".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"enable_ai": {
"type": "boolean",
"description": "Enable AI features during build (default: false)"
},
"translate": {
"type": "boolean",
"description": "Enable automatic translation (default: false)"
}
}
}),
},
Tool {
name: "get_post_content".to_string(),
description: "Get the full content of a blog post by slug".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"slug": {
"type": "string",
"description": "The slug of the blog post to retrieve"
}
},
"required": ["slug"]
}),
},
]
}
}

79
src/mcp/types.rs Normal file
View File

@ -0,0 +1,79 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpRequest {
pub jsonrpc: String,
pub id: Option<serde_json::Value>,
pub method: String,
pub params: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpResponse {
pub jsonrpc: String,
pub id: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<McpError>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpError {
pub code: i32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tool {
pub name: String,
pub description: String,
#[serde(rename = "inputSchema")]
pub input_schema: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub content: Vec<Content>,
#[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
pub is_error: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Content {
#[serde(rename = "type")]
pub content_type: String,
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreatePostRequest {
pub title: String,
pub content: String,
pub tags: Option<Vec<String>>,
pub slug: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListPostsRequest {
pub limit: Option<usize>,
pub offset: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostInfo {
pub title: String,
pub slug: String,
pub date: String,
pub tags: Vec<String>,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildRequest {
pub enable_ai: Option<bool>,
pub translate: Option<bool>,
}