update
This commit is contained in:
6
src/mcp/mod.rs
Normal file
6
src/mcp/mod.rs
Normal 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
148
src/mcp/server.rs
Normal 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
299
src/mcp/tools.rs
Normal 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
79
src/mcp/types.rs
Normal 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>,
|
||||
}
|
Reference in New Issue
Block a user