Add HTTP interface, templates, generate_from_template, unified Dockerfile
Continuous Integration / Test Suite (macos-latest, nightly) (push) Has been cancelled
Continuous Integration / Test Suite (macos-latest, stable) (push) Has been cancelled
Continuous Integration / Test Suite (ubuntu-latest, 1.70.0) (push) Has been cancelled
Continuous Integration / Test Suite (ubuntu-latest, beta) (push) Has been cancelled
Continuous Integration / Test Suite (ubuntu-latest, nightly) (push) Has been cancelled
Continuous Integration / Test Suite (ubuntu-latest, stable) (push) Has been cancelled
Continuous Integration / Test Suite (windows-latest, stable) (push) Has been cancelled
Continuous Integration / Security Audit (push) Has been cancelled
Continuous Integration / Code Coverage (push) Has been cancelled
Continuous Integration / Performance Benchmarks (push) Has been cancelled
Continuous Integration / Memory Safety Check (push) Has been cancelled
Continuous Integration / Docker Build Test (push) Has been cancelled
Continuous Integration / Release Readiness (push) Has been cancelled
Continuous Integration / Integration Tests (push) Has been cancelled
Continuous Integration / Stress Testing (push) Has been cancelled
Continuous Integration / Notify Results (push) Has been cancelled

This commit is contained in:
2026-06-13 00:22:02 +00:00
parent d3fbbcfd7c
commit f655336757
11 changed files with 1789 additions and 45 deletions
+224 -1
View File
@@ -20,6 +20,7 @@ pub struct DocxToolsProvider {
advanced: Arc<AdvancedDocxHandler>,
security: Arc<SecurityMiddleware>,
security_config: SecurityConfig,
templates_dir: PathBuf,
}
impl DocxToolsProvider {
@@ -28,6 +29,10 @@ impl DocxToolsProvider {
}
pub fn new_with_security(security_config: SecurityConfig) -> Self {
Self::new_with_security_and_templates(security_config, PathBuf::from("/templates"))
}
pub fn new_with_security_and_templates(security_config: SecurityConfig, templates_dir: PathBuf) -> Self {
Self {
handler: Arc::new(RwLock::new(DocxHandler::new().expect("Failed to create DocxHandler"))),
converter: Arc::new(DocumentConverter::new()),
@@ -35,6 +40,7 @@ impl DocxToolsProvider {
advanced: Arc::new(AdvancedDocxHandler::new()),
security: Arc::new(SecurityMiddleware::new(security_config.clone())),
security_config,
templates_dir,
}
}
@@ -52,6 +58,7 @@ impl DocxToolsProvider {
advanced: Arc::new(AdvancedDocxHandler::new()),
security: Arc::new(SecurityMiddleware::new(security_config.clone())),
security_config,
templates_dir: PathBuf::from("/templates"),
}
}
}
@@ -956,6 +963,56 @@ impl DocxToolsProvider {
}),
annotations: None,
},
Tool {
name: "list_templates".to_string(),
description: Some("List available document templates from the templates directory".to_string()),
input_schema: json!({
"type": "object",
"properties": {},
"required": []
}),
annotations: None,
},
Tool {
name: "open_template".to_string(),
description: Some("Open a template document by name from the templates directory".to_string()),
input_schema: json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Template file name (e.g., 'nda_template.docx')"
}
},
"required": ["name"]
}),
annotations: None,
},
Tool {
name: "generate_from_template".to_string(),
description: Some(
"Generate a new document from a template by filling placeholders like {{FIELD_NAME}} with provided values".to_string()
),
input_schema: json!({
"type": "object",
"properties": {
"template_name": {
"type": "string",
"description": "Template file name (e.g., 'nda_template.docx')"
},
"output_path": {
"type": "string",
"description": "Output DOCX path (e.g., '/out/nda_filled.docx')"
},
"fields": {
"type": "object",
"description": "Key-value pairs; keys are placeholder names without braces. Example: {\"CLIENT_NAME\": \"Acme Corp\"}"
}
},
"required": ["template_name", "output_path", "fields"]
}),
annotations: None,
},
];
// Filter tools based on security configuration
@@ -1715,7 +1772,173 @@ impl DocxToolsProvider {
Err(e) => ToolOutcome::Error { code: ErrorCode::InternalError, error: e.to_string(), hint: None },
}
},
"list_templates" => {
let mut templates = Vec::new();
if self.templates_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&self.templates_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if ext.eq_ignore_ascii_case("docx") {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
templates.push(name.to_string());
}
}
}
}
}
}
}
templates.sort();
ToolOutcome::Metadata {
metadata: serde_json::json!({ "templates": templates }),
}
},
"open_template" => {
let name = arguments["name"].as_str().unwrap_or("");
if name.is_empty() {
ToolOutcome::Error {
code: ErrorCode::ValidationError,
error: "Template name is required".to_string(),
hint: Some("Provide 'name' with a .docx filename from list_templates".to_string()),
}
} else {
let path = self.templates_dir.join(name);
if !path.exists() || !path.is_file() {
ToolOutcome::Error {
code: ErrorCode::ValidationError,
error: format!("Template not found: {}", name),
hint: Some("Check list_templates for available names".to_string()),
}
} else {
let mut handler = self.handler.write().unwrap();
match handler.open_document(&path) {
Ok(doc_id) => ToolOutcome::Created {
document_id: doc_id,
message: Some(format!("Opened template '{}' as document", name)),
},
Err(e) => ToolOutcome::Error {
code: ErrorCode::InternalError,
error: e.to_string(),
hint: None,
},
}
}
}
},
"generate_from_template" => {
let template_name = arguments["template_name"].as_str().unwrap_or("");
let output_path = arguments["output_path"].as_str().unwrap_or("");
let fields = arguments.get("fields").and_then(|v| v.as_object()).cloned().unwrap_or_default();
if template_name.is_empty() {
ToolOutcome::Error {
code: ErrorCode::ValidationError,
error: "template_name is required".to_string(),
hint: Some("Provide the template file name from list_templates".to_string()),
}
} else if output_path.is_empty() {
ToolOutcome::Error {
code: ErrorCode::ValidationError,
error: "output_path is required".to_string(),
hint: Some("Provide an absolute path where the generated DOCX will be saved".to_string()),
}
} else {
let template_path = self.templates_dir.join(template_name);
if !template_path.exists() || !template_path.is_file() {
ToolOutcome::Error {
code: ErrorCode::ValidationError,
error: format!("Template not found: {}", template_name),
hint: Some("Check list_templates for available names".to_string()),
}
} else {
// Open template
let mut handler = self.handler.write().unwrap();
let doc_id = match handler.open_document(&template_path) {
Ok(id) => id,
Err(e) => {
drop(handler);
return ToolOutcome::Error {
code: ErrorCode::InternalError,
error: e.to_string(),
hint: None,
};
}
};
// Apply field replacements
let mut replace_count = 0usize;
for (key, value) in &fields {
let placeholder = format!("{{{{{}}}}}", key);
let val_str = match value {
Value::String(s) => s.clone(),
_ => value.to_string(),
};
if let Ok(count) = handler.find_and_replace_advanced(
&doc_id,
&placeholder,
&val_str,
false, // case_sensitive: false for placeholders
true, // whole_word: true (treat placeholder as whole token)
false, // use_regex: false
) {
replace_count += count;
}
}
// Save generated document
let out_path = PathBuf::from(output_path);
let result = if out_path.parent().is_some() {
if let Err(e) = std::fs::create_dir_all(out_path.parent().unwrap()) {
drop(handler);
ToolOutcome::Error {
code: ErrorCode::InternalError,
error: format!("Failed to create output directory: {}", e),
hint: None,
}
} else {
match handler.save_document(&doc_id, &out_path) {
Ok(()) => ToolOutcome::Ok {
message: Some(format!(
"Generated document from template '{}' with {} replacements at {}",
template_name, replace_count, output_path
)),
},
Err(e) => ToolOutcome::Error {
code: ErrorCode::InternalError,
error: e.to_string(),
hint: None,
},
}
}
} else {
match handler.save_document(&doc_id, &out_path) {
Ok(()) => ToolOutcome::Ok {
message: Some(format!(
"Generated document from template '{}' with {} replacements at {}",
template_name, replace_count, output_path
)),
},
Err(e) => ToolOutcome::Error {
code: ErrorCode::InternalError,
error: e.to_string(),
hint: None,
},
}
};
// Optionally close template document
let _ = handler.close_document(&doc_id);
drop(handler);
result
}
}
},
_ => {
ToolOutcome::Error { code: ErrorCode::UnknownTool, error: format!("Unknown or unsupported tool: {}", name), hint: None }
}
+202
View File
@@ -0,0 +1,202 @@
use axum::{
extract::{
ws::{Message, WebSocket},
State, WebSocketUpgrade,
},
response::{Html, Response},
routing::{get, post},
Router,
Json,
};
use futures::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::{
net::SocketAddr,
sync::Arc,
};
use tower_http::cors::{Any, CorsLayer};
use tracing::info;
use crate::docx_tools::DocxToolsProvider;
/// Application state shared across HTTP handlers
pub struct AppState {
pub provider: DocxToolsProvider,
}
/// Request to call a tool
#[derive(Debug, Deserialize)]
pub struct ToolCallRequest {
pub name: String,
pub arguments: serde_json::Value,
}
/// Response from a tool call
#[derive(Debug, Serialize)]
pub struct ToolCallResponse {
pub success: bool,
pub content: serde_json::Value,
pub error: Option<String>,
}
/// Response with list of tools
#[derive(Debug, Serialize)]
pub struct ListToolsResponse {
pub success: bool,
pub tools: Vec<serde_json::Value>,
}
/// Start the HTTP server
pub async fn start_http_server(addr: &str, provider: DocxToolsProvider) -> anyhow::Result<()> {
let state = Arc::new(AppState { provider });
let app = Router::new()
.state(state.clone())
// Serve HTML interface
.route("/", get(index_handler))
.route("/api/tools", get(list_tools_handler))
.route("/api/call", post(call_tool_handler))
.route("/ws", get(ws_handler))
// CORS policy - allow all origins on LAN
.layer(CorsLayer::new().allow_origin(Any()).allow_methods(tower_http::cors::Method::any()));
let addr = SocketAddr::from_str(addr).unwrap_or_else(|_| {
info!("Invalid address format, using default 0.0.0.0:3000");
"0.0.0.0:3000".parse().unwrap()
});
info!("Starting HTTP server on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
/// Serve the HTML interface
async fn index_handler() -> Html<String> {
Html(include_str!("../assets/html_interface.html").to_string())
}
/// List available tools
async fn list_tools_handler(State(state): State<Arc<AppState>>) -> Json<ListToolsResponse> {
let tools = state.provider.list_tools().await;
let tool_list: Vec<serde_json::Value> = tools.iter().map(|t| {
serde_json::json!({
"name": t.name,
"description": t.description,
"input_schema": t.input_schema
})
}).collect();
Json(ListToolsResponse {
success: true,
tools: tool_list,
})
}
/// Call a tool via HTTP POST
async fn call_tool_handler(
State(state): State<Arc<AppState>>,
Json(request): Json<ToolCallRequest>,
) -> Json<ToolCallResponse> {
let response = state.provider.call_tool(&request.name, request.arguments).await;
// Convert response to JSON
let content = if let Some(content) = response.content.first() {
match content {
mcp_core::types::ToolResponseContent::Text(text) => {
serde_json::from_str(&text.text).unwrap_or_else(|_| {
serde_json::json!({"text": text.text.clone()})
})
},
mcp_core::types::ToolResponseContent::Image(image) => {
serde_json::json!({
"data": image.data,
"mimeType": image.mime_type
})
},
}
} else {
serde_json::json!({})
};
Json(ToolCallResponse {
success: response.is_error.unwrap_or(false) == false,
content,
error: response.is_error.unwrap_or(false).then(|| "Tool call failed".to_string()),
})
}
/// WebSocket handler for real-time communication
async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>
) -> Result<Response, axum::http::StatusCode> {
ws.on_upgrade(move |socket| async move {
let provider = state.provider.clone();
let mut ws = socket;
// Handle WebSocket messages
while let Some(msg) = ws.recv().await {
let msg = match msg {
Ok(msg) => msg,
Err(_) => continue,
};
let text = match msg {
Message::Text(text) => text.to_string(),
_ => continue,
};
// Parse request
let request: ToolCallRequest = match serde_json::from_str(&text) {
Ok(req) => req,
Err(e) => {
let error_response = ToolCallResponse {
success: false,
content: serde_json::json!({}),
error: Some(format!("Parse error: {}", e)),
};
let _ = ws.send(Message::Text(
serde_json::to_string(&error_response).unwrap_or("{}".to_string())
)).await;
continue;
}
};
// Call tool
let response = provider.call_tool(&request.name, request.arguments).await;
// Convert response to JSON
let content = if let Some(content) = response.content.first() {
match content {
mcp_core::types::ToolResponseContent::Text(text) => {
serde_json::from_str(&text.text).unwrap_or_else(|_| {
serde_json::json!({"text": text.text.clone()})
})
},
mcp_core::types::ToolResponseContent::Image(image) => {
serde_json::json!({
"data": image.data,
"mimeType": image.mime_type
})
},
}
} else {
serde_json::json!({})
};
let ws_response = ToolCallResponse {
success: response.is_error.unwrap_or(false) == false,
content,
error: response.is_error.unwrap_or(false).then(|| "Tool call failed".to_string()),
};
let _ = ws.send(Message::Text(
serde_json::to_string(&ws_response).unwrap_or("{}".to_string())
)).await;
}
})
}
+37 -8
View File
@@ -15,6 +15,8 @@ mod converter;
mod pure_converter;
#[cfg(all(feature = "runtime-server", feature = "advanced-docx"))]
mod advanced_docx;
#[cfg(feature = "http-server")]
mod http_server;
mod security;
#[cfg(feature = "embedded-fonts")]
@@ -53,6 +55,39 @@ async fn main() -> Result<()> {
}
}
// Check if HTTP mode is enabled before consuming args
let http_mode = args.http_mode;
let http_address = args.http_address.clone();
let templates_dir = args.templates_dir.clone();
// Create the tools provider
let security_config = security::SecurityConfig::from_args(args);
info!("Starting DOCX MCP Server - Security: {}", security_config.get_summary());
info!("Templates directory: {}", templates_dir);
let provider = DocxToolsProvider::new_with_security_and_templates(
security_config,
std::path::PathBuf::from(&templates_dir),
);
// Check if HTTP mode is enabled
if http_mode {
#[cfg(feature = "http-server")]
{
let addr = http_address.unwrap_or_else(|| "0.0.0.0:3000".to_string());
info!("Starting in HTTP mode on {}", addr);
return http_server::start_http_server(&addr, provider).await;
}
#[cfg(not(feature = "http-server"))]
{
eprintln!("HTTP mode requires the 'http-server' feature to be enabled during build.");
eprintln!("Rebuild with: cargo build --release --features http-server");
return Err(anyhow::anyhow!("HTTP mode not available"));
}
}
// Default: stdio mode
#[cfg(feature = "runtime-server")]
{
use mcp_server::{Router, Server};
@@ -67,9 +102,6 @@ async fn main() -> Result<()> {
use std::future::Future;
use tokio::io::{stdin, stdout};
let security_config = security::SecurityConfig::from_args(args);
info!("Starting DOCX MCP Server - Security: {}", security_config.get_summary());
#[derive(Clone)]
struct DocxRouter(docx_tools::DocxToolsProvider);
@@ -80,7 +112,6 @@ async fn main() -> Result<()> {
CapabilitiesBuilder::new().with_tools(true).build()
}
fn list_tools(&self) -> Vec<SpecTool> {
// DocxToolsProvider::list_tools is async; block briefly with tokio runtime handle
let rt = tokio::runtime::Handle::current();
let tools = rt.block_on(self.0.list_tools());
tools.into_iter().map(|t| SpecTool{ name: t.name, description: t.description.unwrap_or_default(), input_schema: t.input_schema }).collect()
@@ -90,7 +121,6 @@ async fn main() -> Result<()> {
let name = tool_name.to_string();
Box::pin(async move {
let resp = provider.call_tool(&name, arguments).await;
// Convert our CallToolResponse (text JSON) to Content::text
let text = match resp.content.get(0) {
Some(mcp_core::types::ToolResponseContent::Text(t)) => t.text.clone(),
_ => serde_json::to_string(&resp).unwrap_or_else(|_| "{}".to_string()),
@@ -108,7 +138,7 @@ async fn main() -> Result<()> {
}
}
let router = DocxRouter(DocxToolsProvider::new_with_security(security_config));
let router = DocxRouter(provider);
let service = RouterService(router);
let server = Server::new(service);
let transport = mcp_server::ByteTransport::new(stdin(), stdout());
@@ -117,9 +147,8 @@ async fn main() -> Result<()> {
#[cfg(not(feature = "runtime-server"))]
{
// No runtime server compiled in; if no subcommand was used, exit with guidance
eprintln!("Runtime server disabled. Rebuild with --features runtime-server to run the MCP server.");
}
Ok(())
}
}
+12
View File
@@ -42,6 +42,18 @@ pub struct Args {
#[arg(long, env = "DOCX_MCP_MAX_DOCS")]
pub max_docs: Option<usize>,
/// Enable HTTP server mode for HTML interface
#[arg(long, env = "DOCX_MCP_HTTP")]
pub http_mode: bool,
/// HTTP server address and port (default: 0.0.0.0:3000)
#[arg(long, env = "DOCX_MCP_HTTP_ADDRESS")]
pub http_address: Option<String>,
/// Path to directory containing template .docx files
#[arg(long, env = "DOCX_MCP_TEMPLATES_DIR", default_value = "/templates")]
pub templates_dir: String,
/// Optional top-level subcommand (e.g., fonts download)
#[command(subcommand)]
pub command: Option<CliCommand>,