Compare commits

...

22 Commits

Author SHA1 Message Date
akadmin 20c75d1ca3 Fix tools/call: avoid double-encoding string results 2026-06-13 20:55:27 +00:00
akadmin 205174dfb3 Align with mcp-time-tools: FastAPI Streamable HTTP MCP endpoint 2026-06-13 17:02:18 +00:00
akadmin 57a225b749 Use streamable_http_app() instead of to_asgi_app() 2026-06-13 16:34:59 +00:00
akadmin 8e93b39f0a Use @mcp.tool() with parentheses as required by SDK 2026-06-13 16:24:38 +00:00
akadmin d910c1a817 Fix tool registration: use @mcp.tool decorator without name arg 2026-06-13 16:16:42 +00:00
akadmin fc58fd59ac Flatten layout: move py_docx files into src; update imports and Dockerfile 2026-06-13 15:53:18 +00:00
akadmin af8f6bf2e1 Fix Dockerfile: copy src before pip install; set PYTHONPATH 2026-06-13 15:49:07 +00:00
akadmin 8adb8c8fb0 Fix py_docx import error: use setuptools + PYTHONPATH 2026-06-13 15:45:11 +00:00
akadmin ff034fa97e Add README for Python MCP server 2026-06-13 06:13:12 +00:00
akadmin 438eebc73d Remove Rust project; repo is now Python-only MCP server at root 2026-06-13 06:11:21 +00:00
akadmin 873976c05a Merge python-docx into main
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
2026-06-13 06:07:44 +00:00
akadmin 2883d01cc5 Move Python MCP server into py-docx/ subdirectory 2026-06-13 06:05:19 +00:00
akadmin 269f3b9757 Initial commit: Python MCP server (Streamable HTTP, API key, return documents) 2026-06-13 06:02:13 +00:00
akadmin 899963a14c Fix docx_tools.rs missing imports for json, info, debug, Arc, etc.
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
2026-06-13 04:24:15 +00:00
akadmin c5416bf745 Fix build: add response module, fix http_server, update Cargo.toml
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
2026-06-13 04:02:52 +00:00
akadmin bb547888bf Add build-bin feature to Dockerfile so binary is built
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
2026-06-13 02:20:00 +00:00
akadmin 670e7492bb Fix generate_from_template: correct return type and borrow handling
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
2026-06-13 01:31:23 +00:00
akadmin a742efa73a Fix: remove unused mut on invoice_info table 2026-06-13 01:05:18 +00:00
akadmin e2a456fc9f Use rust:1.90-slim to support edition2024 (fix aligned crate build)
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
2026-06-13 00:56:29 +00:00
akadmin 9d232e5696 Fix Docker build: include benches directory for docx_benchmarks
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
2026-06-13 00:39:55 +00:00
akadmin 0d16ca6f1a Add Cargo.lock and restore it in 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
2026-06-13 00:34:35 +00:00
akadmin 51d6e97553 Remove Cargo.lock dependency from 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
2026-06-13 00:30:51 +00:00
65 changed files with 2089 additions and 18987 deletions
-13
View File
@@ -1,13 +0,0 @@
{
"permissions": {
"allow": [
"Bash(chmod:*)",
"Bash(cargo build:*)",
"Bash(rustc:*)",
"Bash(cargo check:*)",
"Bash(git push:*)",
"Bash(rm:*)"
],
"deny": []
}
}
-58
View File
@@ -1,58 +0,0 @@
---
name: Release Checklist
about: Checklist for preparing a new release
title: 'Release v[VERSION]'
labels: 'release'
assignees: ''
---
## Pre-release Checklist
- [ ] All planned features and fixes are merged
- [ ] All tests are passing on main branch
- [ ] Documentation is updated
- [ ] CHANGELOG.md is updated (if maintained separately)
- [ ] Version is updated in Cargo.toml
- [ ] No critical security vulnerabilities in dependencies
## Release Process
- [ ] Run `./scripts/release.sh [patch|minor|major|version X.Y.Z]`
- [ ] Verify all CI checks pass
- [ ] Tag is created and pushed
- [ ] GitHub release is created automatically
- [ ] Binaries are built for all platforms
- [ ] Crate is published to crates.io (for stable releases)
- [ ] Docker images are pushed
## Post-release Tasks
- [ ] Verify release artifacts are available
- [ ] Test installation from released binaries
- [ ] Update any dependent projects
- [ ] Announce release (if applicable)
## Release Notes
<!--
Add release notes here:
- New features
- Bug fixes
- Breaking changes
- Performance improvements
- Security fixes
-->
## Verification Commands
```bash
# Test the release script (dry run)
./scripts/release.sh patch --dry-run
# Run pre-release checks
./scripts/release.sh check
# Create the actual release
./scripts/release.sh patch # or minor/major/version X.Y.Z
```
-438
View File
@@ -1,438 +0,0 @@
name: Continuous Integration
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
schedule:
# Run tests daily at 2 AM UTC
- cron: '0 2 * * *'
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
test:
name: Test Suite
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
rust: [stable, beta, nightly]
exclude:
# Reduce matrix size by excluding some combinations
- os: windows-latest
rust: beta
- os: windows-latest
rust: nightly
- os: macos-latest
rust: beta
include:
# Add minimum supported Rust version
- os: ubuntu-latest
rust: 1.70.0
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.rust }}
components: rustfmt, clippy
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ matrix.rust }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-${{ matrix.rust }}-
${{ runner.os }}-cargo-
- name: Install system dependencies (Ubuntu)
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Install system dependencies (macOS)
if: matrix.os == 'macos-latest'
run: |
brew update
brew install pkg-config freetype jpeg libpng
- name: Check code formatting
if: matrix.rust == 'stable'
run: cargo fmt --all -- --check
- name: Run Clippy lints (library only)
if: matrix.rust == 'stable'
run: cargo clippy --lib -- -D warnings
- name: Build project (no extra features)
run: cargo build --verbose
- name: Run unit tests
run: cargo test --verbose --lib
- name: Run integration tests (opt-in)
if: contains(github.event.head_commit.message, '[integration]')
run: cargo test --verbose --test args_tests
- name: Run doc tests (opt-in)
if: contains(github.event.head_commit.message, '[full-ci]')
run: cargo test --verbose --doc
- name: Test with minimal features (opt-in)
if: contains(github.event.head_commit.message, '[full-ci]')
run: cargo test --verbose --no-default-features --lib
- name: Test with all features (opt-in)
if: contains(github.event.head_commit.message, '[full-ci]')
run: cargo test --verbose --all-features --lib
security:
name: Security Audit
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ubuntu-cargo-audit-${{ hashFiles('**/Cargo.lock') }}
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Run security audit
run: cargo audit
- name: Install cargo-deny
run: cargo install cargo-deny
- name: Check licenses and dependencies
run: cargo deny check
coverage:
name: Code Coverage
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Generate coverage report (library only)
run: |
cargo llvm-cov --lib --workspace --lcov --output-path lcov.info
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: lcov.info
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
benchmarks:
name: Performance Benchmarks
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[bench]')
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ubuntu-cargo-bench-${{ hashFiles('**/Cargo.lock') }}
- name: Run benchmarks
run: cargo bench --all-features
- name: Store benchmark results
uses: benchmark-action/github-action-benchmark@v1
with:
tool: 'cargo'
output-file-path: target/criterion/reports/index.html
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: true
comment-on-alert: true
alert-threshold: '200%'
fail-on-alert: true
memory-safety:
name: Memory Safety Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly
with:
components: rust-src
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Install Miri
run: rustup component add miri
- name: Run Miri tests
run: |
cargo miri setup
# Run a subset of tests with Miri (full test suite might be too slow)
cargo miri test --lib -- --test-threads=1
env:
MIRIFLAGS: -Zmiri-strict-provenance
docker:
if: contains(github.event.head_commit.message, '[docker]')
name: Docker Build Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
run: |
cat > Dockerfile << 'EOF'
FROM rust:1.75 as builder
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
libssl3 \
libfontconfig1 \
libfreetype6 \
libjpeg62-turbo \
libpng16-16 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/docx-mcp /usr/local/bin/
EXPOSE 8080
CMD ["docx-mcp"]
EOF
docker buildx build --tag docx-mcp:test .
- name: Test Docker container
run: |
# Start container in background
docker run -d --name docx-mcp-test -p 8080:8080 docx-mcp:test
sleep 10
# Basic health check (adapt based on your server's health endpoint)
docker logs docx-mcp-test
docker stop docx-mcp-test
docker rm docx-mcp-test
release-check:
name: Release Readiness
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Check that release builds (library only)
run: cargo build --release
- name: Verify package can be published
run: cargo package --dry-run
- name: Generate documentation
run: cargo doc --all-features --no-deps
- name: Check documentation links
run: cargo doc --all-features --no-deps --open || true
integration:
if: contains(github.event.head_commit.message, '[integration]')
name: Integration Tests
runs-on: ubuntu-latest
services:
# Add any services your integration tests might need
# For example, if you need a test database or cache
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Run integration tests (focused)
run: |
export RUST_LOG=debug
cargo test --test args_tests -- --nocapture --test-threads=1
env:
RUST_LOG: debug
stress-test:
name: Stress Testing
runs-on: ubuntu-latest
if: github.event_name == 'schedule' || contains(github.event.head_commit.message, '[stress-test]')
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Build in release mode
run: cargo build --release --all-features
- name: Run stress tests
run: |
export STRESS_TEST=1
export RUST_LOG=info
cargo test --release --test performance_tests -- --ignored --test-threads=1
cargo test --release --test e2e_workflow_tests -- --ignored --test-threads=1
notify:
name: Notify Results
runs-on: ubuntu-latest
needs: [test, security, coverage, benchmarks]
if: always() && (github.event_name == 'push' && github.ref == 'refs/heads/main')
steps:
- name: Notify on success
if: ${{ needs.test.result == 'success' && needs.security.result == 'success' && (needs.coverage.result == 'success' || needs.coverage.result == 'skipped') }}
run: |
echo "✅ All CI checks passed for main branch!"
# Add webhook notification here if needed
- name: Notify on failure
if: ${{ needs.test.result == 'failure' || needs.security.result == 'failure' || needs.coverage.result == 'failure' }}
run: |
echo "❌ CI checks failed for main branch!"
# Add failure notification here if needed
exit 1
-490
View File
@@ -1,490 +0,0 @@
name: Release
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., v1.0.0)'
required: true
type: string
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
validate-release:
name: Validate Release
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get-version.outputs.version }}
tag: ${{ steps.get-version.outputs.tag }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get version from tag or input
id: get-version
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
VERSION="${{ github.event.inputs.version }}"
echo "version=${VERSION#v}" >> $GITHUB_OUTPUT
echo "tag=${VERSION}" >> $GITHUB_OUTPUT
else
VERSION="${GITHUB_REF#refs/tags/}"
echo "version=${VERSION#v}" >> $GITHUB_OUTPUT
echo "tag=${VERSION}" >> $GITHUB_OUTPUT
fi
- name: Validate version format
run: |
VERSION="${{ steps.get-version.outputs.version }}"
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Invalid version format: $VERSION"
echo "Expected format: X.Y.Z or X.Y.Z-suffix"
exit 1
fi
echo "✅ Valid version format: $VERSION"
- name: Check if version matches Cargo.toml
run: |
CARGO_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
INPUT_VERSION="${{ steps.get-version.outputs.version }}"
if [ "$CARGO_VERSION" != "$INPUT_VERSION" ]; then
echo "❌ Version mismatch:"
echo " Cargo.toml: $CARGO_VERSION"
echo " Tag/Input: $INPUT_VERSION"
exit 1
fi
echo "✅ Version matches Cargo.toml: $INPUT_VERSION"
test:
name: Run Tests
runs-on: ${{ matrix.os }}
needs: validate-release
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
rust: [stable]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.rust }}
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
- name: Install system dependencies (Ubuntu)
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Install system dependencies (macOS)
if: matrix.os == 'macos-latest'
run: |
brew update
brew install pkg-config freetype jpeg libpng
- name: Run tests (library only)
run: cargo test --verbose --lib
build:
name: Build Release Artifacts
runs-on: ${{ matrix.job.os }}
needs: [validate-release, test]
strategy:
matrix:
job:
- { os: ubuntu-latest, target: x86_64-unknown-linux-gnu, use-cross: false }
- { os: ubuntu-latest, target: x86_64-unknown-linux-musl, use-cross: true }
- { os: ubuntu-latest, target: aarch64-unknown-linux-gnu, use-cross: true }
- { os: ubuntu-latest, target: aarch64-unknown-linux-musl, use-cross: true }
- { os: windows-latest, target: x86_64-pc-windows-msvc, use-cross: false }
- { os: macos-latest, target: x86_64-apple-darwin, use-cross: false }
- { os: macos-latest, target: aarch64-apple-darwin, use-cross: false }
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.job.target }}
- name: Install cross compilation tools
if: matrix.job.use-cross
run: |
cargo install cross --git https://github.com/cross-rs/cross
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-${{ matrix.job.target }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
- name: Install system dependencies (Ubuntu)
if: matrix.job.os == 'ubuntu-latest' && !matrix.job.use-cross
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Install system dependencies (macOS)
if: matrix.job.os == 'macos-latest'
run: |
brew update
brew install pkg-config freetype jpeg libpng
- name: Build release binary
run: |
if [ "${{ matrix.job.use-cross }}" = "true" ]; then
cross build --release --target ${{ matrix.job.target }}
else
cargo build --release --target ${{ matrix.job.target }}
fi
- name: Prepare release archive
shell: bash
run: |
VERSION="${{ needs.validate-release.outputs.version }}"
TARGET="${{ matrix.job.target }}"
# Create staging directory
mkdir -p staging
# Copy binary
if [[ "${{ matrix.job.os }}" == "windows-latest" ]]; then
cp "target/${TARGET}/release/docx-mcp.exe" staging/
BINARY="docx-mcp.exe"
else
cp "target/${TARGET}/release/docx-mcp" staging/
BINARY="docx-mcp"
fi
# Copy additional files
cp README.md staging/
cp LICENSE staging/
# Create archive name
ARCHIVE="docx-mcp-${VERSION}-${TARGET}"
# Create archive
cd staging
if [[ "${{ matrix.job.os }}" == "windows-latest" ]]; then
7z a "../${ARCHIVE}.zip" *
echo "ARCHIVE=${ARCHIVE}.zip" >> $GITHUB_ENV
else
tar czf "../${ARCHIVE}.tar.gz" *
echo "ARCHIVE=${ARCHIVE}.tar.gz" >> $GITHUB_ENV
fi
cd ..
# Generate checksums
if [[ "${{ matrix.job.os }}" == "windows-latest" ]]; then
certutil -hashfile "${ARCHIVE}.zip" SHA256 > "${ARCHIVE}.zip.sha256"
else
shasum -a 256 "${ARCHIVE}.tar.gz" > "${ARCHIVE}.tar.gz.sha256"
fi
echo "BINARY=${BINARY}" >> $GITHUB_ENV
- name: Upload release artifacts
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.job.target }}
path: |
${{ env.ARCHIVE }}
${{ env.ARCHIVE }}.sha256
create-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: [validate-release, build]
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Prepare release assets
run: |
mkdir -p release-assets
find artifacts -type f -name "*.tar.gz" -o -name "*.zip" -o -name "*.sha256" | \
xargs -I {} cp {} release-assets/
ls -la release-assets/
- name: Generate changelog
id: changelog
run: |
VERSION="${{ needs.validate-release.outputs.version }}"
TAG="${{ needs.validate-release.outputs.tag }}"
# Get previous tag
PREV_TAG=$(git tag --sort=-version:refname | grep -v "^${TAG}$" | head -1)
echo "Generating changelog from ${PREV_TAG} to ${TAG}"
# Generate changelog
if [ -n "$PREV_TAG" ]; then
CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges ${PREV_TAG}..HEAD)
else
CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges)
fi
# Create release notes
cat > release-notes.md << EOF
## What's Changed
${CHANGELOG}
## Installation
### Pre-built Binaries
Download the appropriate binary for your system:
- **Linux x86_64**: \`docx-mcp-${VERSION}-x86_64-unknown-linux-gnu.tar.gz\`
- **Linux x86_64 (musl)**: \`docx-mcp-${VERSION}-x86_64-unknown-linux-musl.tar.gz\`
- **Linux ARM64**: \`docx-mcp-${VERSION}-aarch64-unknown-linux-gnu.tar.gz\`
- **macOS Intel**: \`docx-mcp-${VERSION}-x86_64-apple-darwin.tar.gz\`
- **macOS Apple Silicon**: \`docx-mcp-${VERSION}-aarch64-apple-darwin.tar.gz\`
- **Windows x86_64**: \`docx-mcp-${VERSION}-x86_64-pc-windows-msvc.zip\`
### From Source
\`\`\`bash
cargo install --git https://github.com/hongkongkiwi/docx-mcp --tag ${TAG}
\`\`\`
### Verification
All binaries are provided with SHA256 checksums for verification:
\`\`\`bash
# Linux/macOS
shasum -a 256 -c docx-mcp-${VERSION}-your-target.tar.gz.sha256
# Windows
certutil -hashfile docx-mcp-${VERSION}-x86_64-pc-windows-msvc.zip SHA256
\`\`\`
## Full Changelog
**Full Changelog**: https://github.com/hongkongkiwi/docx-mcp/compare/${PREV_TAG}...${TAG}
EOF
echo "CHANGELOG_FILE=release-notes.md" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.validate-release.outputs.tag }}
name: Release ${{ needs.validate-release.outputs.tag }}
body_path: ${{ steps.changelog.outputs.CHANGELOG_FILE }}
files: release-assets/*
draft: false
prerelease: ${{ contains(needs.validate-release.outputs.version, '-') }}
generate_release_notes: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-crate:
name: Publish to crates.io
runs-on: ubuntu-latest
needs: [validate-release, create-release]
if: ${{ !contains(needs.validate-release.outputs.version, '-') }} # Only publish stable releases
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ubuntu-cargo-publish-${{ hashFiles('**/Cargo.lock') }}
- name: Verify package
run: cargo package --dry-run
- name: Publish to crates.io
run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
docker-release:
name: Build and Push Docker Images
runs-on: ubuntu-latest
needs: [validate-release, create-release]
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
if: ${{ secrets.DOCKERHUB_USERNAME && secrets.DOCKERHUB_TOKEN }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository }}
${{ secrets.DOCKERHUB_USERNAME && format('{0}/docx-mcp', secrets.DOCKERHUB_USERNAME) || '' }}
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
update-docs:
name: Update Documentation
runs-on: ubuntu-latest
needs: [validate-release, create-release]
permissions:
contents: write
pages: write
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Generate documentation
run: |
cargo doc --all-features --no-deps
echo '<meta http-equiv="refresh" content="0; url=docx_mcp">' > target/doc/index.html
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./target/doc
cname: docs.example.com # Replace with your custom domain if you have one
notify-success:
name: Notify Release Success
runs-on: ubuntu-latest
needs: [create-release, publish-crate, docker-release, update-docs]
if: success()
steps:
- name: Success notification
run: |
echo "🎉 Release ${{ needs.validate-release.outputs.tag }} completed successfully!"
echo "- ✅ GitHub release created"
echo "- ✅ Binaries built for all platforms"
echo "- ✅ Published to crates.io"
echo "- ✅ Docker images pushed"
echo "- ✅ Documentation updated"
notify-failure:
name: Notify Release Failure
runs-on: ubuntu-latest
needs: [validate-release, test, build, create-release, publish-crate, docker-release, update-docs]
if: failure()
steps:
- name: Failure notification
run: |
echo "❌ Release ${{ needs.validate-release.outputs.tag }} failed!"
echo "Please check the workflow logs for details."
exit 1
-67
View File
@@ -1,67 +0,0 @@
/example/MCP-Doc
# Rust
target/
Cargo.lock
**/*.rs.bk
*.pdb
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Test outputs
*.docx
*.pdf
*.png
*.jpg
*.jpeg
images/
thumbnails/
output/
# Temporary files
/tmp/
*.tmp
*.temp
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.venv
# Logs
*.log
logs/
# Documentation build
/docs/_build/
/docs/.doctrees/
# Coverage
*.gcov
*.gcda
*.gcno
coverage/
lcov.info
# Profiling
*.prof
perf.data
perf.data.old
# Dependencies
node_modules/
vendor/
-159
View File
@@ -1,159 +0,0 @@
[package]
name = "docx-mcp"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
description = "A comprehensive Model Context Protocol (MCP) server for Microsoft Word DOCX file manipulation"
documentation = "https://docs.rs/docx-mcp"
homepage = "https://github.com/hongkongkiwi/docx-mcp"
repository = "https://github.com/hongkongkiwi/docx-mcp"
readme = "README.md"
license = "MIT"
keywords = ["mcp", "docx", "word", "document", "pdf"]
categories = ["text-processing", "api-bindings", "command-line-utilities"]
exclude = [
"/.github/*",
"/tests/fixtures/*",
"/example/*",
"/benches/*",
"/.gitignore",
"/deny.toml"
]
[dependencies]
# Official MCP SDK
mcp-server = "0.1"
mcp-core = "0.1"
mcp-spec = "0.1"
# Async runtime
tokio = { version = "1.40", features = ["full"] }
async-trait = "0.1"
# DOCX manipulation (pure Rust)
docx-rs = "0.4"
zip = "0.6"
quick-xml = "0.36"
# Pure Rust text extraction from DOCX
roxmltree = "0.20" # XML parsing without external deps
# PDF generation (pure Rust)
printpdf = "0.7"
lopdf = "0.34"
rusttype = "0.9" # Font rendering in pure Rust
# Embedded fonts for PDF
include-bytes-plus = "1.0"
# Image processing (pure Rust)
image = { version = "0.25", features = ["png", "jpeg", "webp", "bmp", "gif"] }
imageproc = "0.25"
resvg = "0.44" # SVG rendering in pure Rust
tiny-skia = "0.11" # 2D graphics in pure Rust
usvg = "0.44" # SVG parsing
# HTML/Markdown to PDF (pure Rust alternatives)
pulldown-cmark = "0.12" # Markdown parsing
html5ever = "0.29" # HTML parsing
comrak = "0.28" # CommonMark parsing
html-escape = "0.2"
# Text extraction from DOCX
dotext = "0.1"
# Template rendering (pure Rust)
handlebars = "6.0" # Template engine
tera = { version = "1.20", optional = true }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
ureq = { version = "2.10", features = ["tls"] }
flate2 = { version = "1.0", features = ["rust_backend"] }
tar = "0.4"
sha2 = "0.10"
# Error handling and logging
anyhow = "1.0"
thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# File handling
tempfile = "3.10"
walkdir = "2.5"
# Additional utilities
uuid = { version = "1.10", features = ["v4", "serde"] }
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
regex = "1.10"
once_cell = "1.20"
# Command line argument parsing
clap = { version = "4.5", features = ["derive", "env"] }
# HTTP server for HTML interface
axum = { version = "0.7", features = ["ws", "json"] }
tower-http = { version = "0.5", features = ["cors"] }
hyper = { version = "1.4", features = ["full"] }
tokio-tungstenite = "0.21"
# Optional external tool support
headless_chrome = { version = "1.0", optional = true }
wkhtmltopdf = { version = "0.4", optional = true }
[features]
default = ["embedded-fonts", "pure-rust-pdf"]
runtime-server = []
http-server = []
advanced-docx = []
embedded-fonts = []
pure-rust-pdf = []
external-tools = ["headless_chrome", "wkhtmltopdf"]
full = ["embedded-fonts", "pure-rust-pdf", "external-tools", "tera"]
build-bin = []
hi-fidelity = [] # placeholder feature flag for high-fidelity rendering backends
hi-fidelity-tables = [] # enable XML injection for true table merges/widths
hi-fidelity-sections = [] # enable XML injection for sectPr (page setup)
hi-fidelity-styles = [] # enable XML injection for custom styles (e.g., TableHeader)
hi-fidelity-lists = [] # enable XML injection for robust numbering definitions
hi-fidelity-toc = [] # enable XML injection for Table of Contents field
hi-fidelity-bookmarks = [] # enable XML injection for bookmarks
hi-fidelity-comments = [] # enable XML injection for comments
hi-fidelity-revisions = [] # enable XML injection for track changes settings
[build-dependencies]
anyhow = "1.0"
[[bin]]
name = "docx-mcp"
path = "src/main.rs"
required-features = ["build-bin"]
[lib]
name = "docx_mcp"
path = "src/lib.rs"
[dev-dependencies]
# Testing framework
tokio-test = "0.4"
assert_matches = "1.5"
pretty_assertions = "1.4"
rstest = "0.18"
test-log = "0.2"
# Test utilities
tempfile = "3.10"
uuid = { version = "1.10", features = ["v4"] }
criterion = { version = "0.5", features = ["html_reports"] }
# Mock and fixtures
mockito = "1.4"
serde_yaml = "0.9"
[[bench]]
name = "docx_benchmarks"
harness = false
-338
View File
@@ -1,338 +0,0 @@
# docx-mcp Server - Deployment Guide
## Server Architecture
This MCP server supports:
- **stdio mode** (default): stdin/stdout for MCP clients.
- **HTTP mode**: Web interface for HTML/browser access over LAN.
- **Templates directory**: User-provided .docx templates for reuse and fill-in generation.
- **High-fidelity PDF conversion**: Via LibreOffice (included in Docker image).
```
┌─────────────────────────────────────────────────────────────────────────┐
│ DEPLOYMENT MODES │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Mode 1: stdio (Local MCP Clients) │
│ ┌───────────┐ stdio ┌──────────────────┐ │
│ │ MCP │ ◄────────► │ docx-mcp │ │
│ │ Client │ │ (container) │ │
│ └───────────┘ └──────────────────┘ │
│ │
│ Mode 2: HTTP (HTML Interface - LAN) │
│ ┌───────────┐ HTTP:3000 ┌──────────────────┐ │
│ │ Browser │ ◄────────────►│ docx-mcp │ │
│ │ (HTML) │ │ (container) │ │
│ └───────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
## Docker Image
There is now a single, unified Dockerfile that includes:
- HTTP server (HTML interface)
- stdio MCP transport
- LibreOffice (high-fidelity PDF conversion)
- Templates directory support
- Sandboxed, non-root configuration
Build:
```bash
docker build -t docx-mcp:full .
```
## Deployment
### HTTP Mode (HTML Interface - LAN)
Run the HTTP server with templates and output directories mounted:
```bash
docker run --rm \
--name docx-mcp-http \
-p 3000:3000 \
-v /host/path/templates:/templates:ro \
-v /host/path/output:/out \
-e DOCX_MCP_HTTP=true \
-e DOCX_MCP_HTTP_ADDRESS=0.0.0.0:3000 \
-e DOCX_MCP_TEMPLATES_DIR=/templates \
-e DOCX_MCP_MAX_SIZE=104857600 \
-e DOCX_MCP_MAX_DOCS=30 \
--memory 1g \
--cpus 1.5 \
docx-mcp:full
```
Access:
- HTML Interface: http://your-server-ip:3000
- API: http://your-server-ip:3000/api/tools
- WebSocket: ws://your-server-ip:3000/ws
### stdio Mode (for MCP Clients)
Useful when launched by an MCP client (e.g., Claude Desktop, Cursor).
```bash
docker run --rm \
--name docx-mcp-stdio \
-v /host/path/templates:/templates:ro \
-v /host/path/output:/out \
-e DOCX_MCP_TEMPLATES_DIR=/templates \
-e DOCX_MCP_MAX_SIZE=104857600 \
-e DOCX_MCP_MAX_DOCS=30 \
--memory 1g \
--cpus 1.5 \
docx-mcp:full
```
In MCP client config, point "command" to "docker run" with these flags.
## Server Configuration
### Command Line Arguments
| Argument | Environment Variable | Description |
|----------|---------------------|-------------|
| `--http-mode` | `DOCX_MCP_HTTP=true` | Enable HTTP server mode |
| `--http-address` | `DOCX_MCP_HTTP_ADDRESS` | HTTP server address (default: 0.0.0.0:3000) |
| `--templates-dir` | `DOCX_MCP_TEMPLATES_DIR` | Directory with template .docx files (default: /templates) |
| `--readonly` | `DOCX_MCP_READONLY=true` | Enable readonly mode |
| `--sandbox` | `DOCX_MCP_SANDBOX=true` | Enable sandbox mode |
| `--no-external-tools` | `DOCX_MCP_NO_EXTERNAL_TOOLS=true` | Disable external tools (e.g., LibreOffice) |
| `--no-network` | `DOCX_MCP_NO_NETWORK=true` | Disable network operations |
| `--max-size` | `DOCX_MCP_MAX_SIZE` | Max document size in bytes |
| `--max-docs` | `DOCX_MCP_MAX_DOCS` | Max concurrent open documents |
| `--whitelist` | `DOCX_MCP_WHITELIST` | Allowed tools (comma-separated) |
| `--blacklist` | `DOCX_MCP_BLACKLIST` | Blocked tools (comma-separated) |
### Example Configurations
- HTTP mode with templates:
```bash
docker run --rm \
-p 3000:3000 \
-v /host/path/templates:/templates:ro \
-e DOCX_MCP_HTTP=true \
-e DOCX_MCP_TEMPLATES_DIR=/templates \
docx-mcp:full
```
- Readonly HTTP mode (limited tools):
```bash
docker run --rm \
-p 3000:3000 \
-e DOCX_MCP_HTTP=true \
-e DOCX_MCP_READONLY=true \
-e DOCX_MCP_WHITELIST="list_templates,open_template,extract_text,get_metadata,search_text" \
docx-mcp:full
```
## API Endpoints
### HTML Interface
- GET / — Web interface (tool browser + templates panel)
### REST API
- GET /api/tools — List available tools
- POST /api/call — Call a tool
### WebSocket
- WS /ws — Real-time communication
### API Examples
- List tools:
```bash
curl http://localhost:3000/api/tools
```
- Call a tool:
```bash
curl -X POST http://localhost:3000/api/call \
-H "Content-Type: application/json" \
-d '{
"name": "create_document",
"arguments": {}
}'
```
- List templates:
```bash
curl -X POST http://localhost:3000/api/call \
-H "Content-Type: application/json" \
-d '{
"name": "list_templates",
"arguments": {}
}'
```
- Open a template:
```bash
curl -X POST http://localhost:3000/api/call \
-H "Content-Type: application/json" \
-d '{
"name": "open_template",
"arguments": { "name": "nda_template.docx" }
}'
```
- Generate from template with fill-in fields:
```bash
curl -X POST http://localhost:3000/api/call \
-H "Content-Type: application/json" \
-d '{
"name": "generate_from_template",
"arguments": {
"template_name": "nda_template.docx",
"output_path": "/out/nda_acme.docx",
"fields": {
"CLIENT_NAME": "Acme Corp",
"EFFECTIVE_DATE": "2025-11-09"
}
}
}'
```
## Docker Compose (Production)
Example with HTTP mode, templates, and output volumes:
```yaml
version: '3.8'
services:
docx-mcp:
image: docx-mcp:full
build:
context: .
dockerfile: Dockerfile
read_only: true
cap_drop:
- ALL
tmpfs:
- /tmp/docx-mcp:rw,noexec,nosuid,size=200m
volumes:
- ./templates:/templates:ro
- ./output:/out
ports:
- "3000:3000"
environment:
- RUST_LOG=info
- DOCX_MCP_HTTP=true
- DOCX_MCP_HTTP_ADDRESS=0.0.0.0:3000
- DOCX_MCP_TEMPLATES_DIR=/templates
- DOCX_MCP_MAX_SIZE=104857600
- DOCX_MCP_MAX_DOCS=30
deploy:
resources:
limits:
memory: 1G
cpus: '1.5'
restart: unless-stopped
healthcheck:
test: ["CMD", "/usr/local/bin/docx-mcp", "--version"]
interval: 30s
timeout: 5s
retries: 3
```
## Security Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `DOCX_MCP_HTTP` | `false` | Enable HTTP mode |
| `DOCX_MCP_HTTP_ADDRESS` | `0.0.0.0:3000` | HTTP server address |
| `DOCX_MCP_TEMPLATES_DIR` | `/templates` | Templates directory |
| `DOCX_MCP_READONLY` | `false` | Restrict to read-only operations |
| `DOCX_MCP_SANDBOX` | `true` | Restrict file operations to temp |
| `DOCX_MCP_NO_EXTERNAL_TOOLS` | `true` | Disable external tools |
| `DOCX_MCP_NO_NETWORK` | `true` | Disable network access |
| `DOCX_MCP_MAX_SIZE` | `104857600` | Max document size (bytes) |
| `DOCX_MCP_MAX_DOCS` | `30` | Max concurrent documents |
| `DOCX_MCP_WHITELIST` | - | Allowed tools (comma-separated) |
| `DOCX_MCP_BLACKLIST` | - | Blocked tools (comma-separated) |
### Security Profiles
- Readonly HTTP mode:
```bash
docker run --rm \
-p 3000:3000 \
-e DOCX_MCP_HTTP=true \
-e DOCX_MCP_READONLY=true \
-e DOCX_MCP_WHITELIST="list_templates,open_template,extract_text,get_metadata,search_text" \
docx-mcp:full
```
- Maximum security:
```bash
docker run --rm \
-p 3000:3000 \
--read-only \
--cap-drop ALL \
--tmpfs /tmp/docx-mcp \
-e DOCX_MCP_HTTP=true \
-e DOCX_MCP_READONLY=true \
-e DOCX_MCP_SANDBOX=true \
-e DOCX_MCP_NO_EXTERNAL_TOOLS=true \
-e DOCX_MCP_NO_NETWORK=true \
docx-mcp:full
```
## Monitoring
```bash
# View logs
docker logs -f docx-mcp-http
# Check resource usage
docker stats docx-mcp-http
# Verify security
docker inspect --format='{{.HostConfig.ReadOnly}}' docx-mcp-http # Should be true
```
## Troubleshooting
### Common Issues
1. Port already in use:
- Use a different port:
- -p 8080:8080 -e DOCX_MCP_HTTP_ADDRESS=0.0.0.0:8080
2. Permission denied on temp directory:
- Ensure temp directory is writable:
- --tmpfs /tmp/docx-mcp:rw
3. Out of memory:
- Increase memory:
- --memory 2g
4. CORS issues in browser:
- CORS is enabled for all origins on LAN by default.
- For production, restrict to specific origins as needed.
## API Key
No API key is required. Security relies on:
- OS-level access controls
- Container isolation
- Built-in command security (whitelist/blacklist)
For LAN deployments, rely on:
- Network-level access controls
- Firewall rules
- Application-level authentication at the bridge
-153
View File
@@ -1,153 +0,0 @@
# docx-mcp Server - Deployment Quick Reference
## Key Facts
| Item | Value |
|------|-------|
| **Transport Method** | stdio (stdin/stdout) |
| **Network Port** | Not required for local use |
| **API Key** | Not required |
| **Authentication** | OS-level + container security |
---
## Port Requirements
### Local Deployment (Recommended)
**No port required** - the server communicates via stdin/stdout directly.
### Remote Deployment (Optional)
If remote access is needed, wrap with a stdio-to-network bridge:
| Bridge Type | Port | Protocol |
|-------------|------|----------|
| WebSocket | 8080 | ws:// |
| TCP | 8080 | tcp:// |
---
## Quick Start
### Build
```bash
# Minimal (recommended)
docker build -f Dockerfile.sandboxed -t docx-mcp:sandboxed .
# With LibreOffice (better PDF conversion)
docker build -f Dockerfile.libreoffice -t docx-mcp:libreoffice .
```
### Run (Local)
```bash
docker run --rm \
--name docx-mcp \
--read-only \
--cap-drop ALL \
--tmpfs /tmp/docx-mcp \
--memory 512m \
docx-mcp:sandboxed
```
### Run (Remote via Docker Compose)
```bash
docker-compose up -d
```
---
## MCP Client Configuration
### Claude Desktop
```json
{
"mcpServers": {
"docx": {
"command": "docker",
"args": [
"run", "--rm", "--read-only", "--cap-drop ALL",
"--tmpfs /tmp/docx-mcp", "--memory 512m",
"docx-mcp:sandboxed"
]
}
}
}
```
### Cursor
```json
{
"mcp": {
"servers": {
"docx": {
"command": "docker",
"args": [
"run", "--rm", "--read-only", "--cap-drop ALL",
"--tmpfs /tmp/docx-mcp", "--memory 512m",
"docx-mcp:sandboxed"
]
}
}
}
}
```
---
## Security Profiles
### Readonly Mode
```bash
docker run --rm \
-e DOCX_MCP_READONLY=true \
-e DOCX_MCP_WHITELIST="open_document,extract_text,get_metadata,search_text" \
docx-mcp:sandboxed
```
### Maximum Security
```bash
docker run --rm \
--read-only \
--cap-drop ALL \
--network none \
--tmpfs /tmp/docx-mcp \
-e DOCX_MCP_READONLY=true \
-e DOCX_MCP_SANDBOX=true \
-e DOCX_MCP_NO_EXTERNAL_TOOLS=true \
-e DOCX_MCP_NO_NETWORK=true \
docx-mcp:sandboxed
```
---
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `DOCX_MCP_READONLY` | `false` | Restrict to read-only operations |
| `DOCX_MCP_SANDBOX` | `true` | Restrict file operations to temp |
| `DOCX_MCP_NO_EXTERNAL_TOOLS` | `true` | Disable LibreOffice etc. |
| `DOCX_MCP_NO_NETWORK` | `true` | Disable network access |
| `DOCX_MCP_MAX_SIZE` | `52428800` | Max document size (bytes) |
| `DOCX_MCP_MAX_DOCS` | `20` | Max concurrent documents |
| `DOCX_MCP_WHITELIST` | - | Allowed tools (comma-separated) |
| `DOCX_MCP_BLACKLIST` | - | Blocked tools (comma-separated) |
---
## Files Created
| File | Description |
|------|-------------|
| `Dockerfile.sandboxed` | Minimal security-focused image |
| `Dockerfile.libreoffice` | Full features with LibreOffice |
| `docker-compose.yml` | Production deployment config |
| `DEPLOYMENT.md` | Comprehensive deployment guide |
---
## Summary
- **Port Required:** No (for local) / 8080 (for remote with bridge)
- **API Key:** No
- **Authentication:** Container isolation + OS controls
- **Recommended:** Local stdio transport with security features enabled
+55 -75
View File
@@ -1,93 +1,73 @@
# Unified Dockerfile for docx-mcp # Dockerfile for py-docx-mcp (Python MCP server) - OpenWebUI: MCP (Streamable HTTP)
# Features: # Usage:
# - HTTP mode (HTML interface) + stdio mode # docker build -t py-docx-mcp .
# - LibreOffice for high-fidelity PDF conversion # docker run --rm -p 3000:3000 py-docx-mcp
# - Templates directory support #
# - Sandboxed, non-root, read-only filesystem where possible # In OpenWebUI:
# - Type: MCP (Streamable HTTP)
# - URL: http://<host>:3000
# - Auth: Bearer (if DOCX_MCP_API_KEY is set)
#
# Environment:
# DOCX_MCP_API_KEY - API key (Bearer). Optional but recommended.
# DOCX_MCP_HTTP_HOST - Bind host (default: 0.0.0.0)
# DOCX_MCP_HTTP_PORT - Bind port (default: 3000)
# DOCX_MCP_TEMPLATES_DIR - Templates directory (default: /templates)
# DOCX_MCP_MAX_SIZE - Max document size in bytes (default: 104857600)
# DOCX_MCP_MAX_DOCS - Max open documents (default: 30)
# DOCX_MCP_SANDBOX - Enable sandbox mode (default: true)
# DOCX_MCP_ALLOW_EXTERNAL_TOOLS - Allow external tools (default: false)
# DOCX_MCP_ALLOW_NETWORK - Allow network access (default: false)
# ============================================================ FROM python:3.12-slim AS base
# Build Stage
# ============================================================
FROM rust:1.80-slim AS builder
# Install build dependencies ENV PYTHONDONTWRITEBYTECODE=1 \
RUN apt-get update && apt-get install -y --no-install-recommends \ PYTHONUNBUFFERED=1 \
pkg-config \ PIP_NO_CACHE_DIR=off \
libssl-dev \ PIP_DISABLE_PIP_VERSION_CHECK=1
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev \
build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# Copy manifests and source # System deps (for python-docx, Pillow, and optional external converters)
COPY Cargo.toml Cargo.lock build.rs ./
COPY src/ ./src/
COPY assets/ ./assets/
# Build with all key features enabled:
# - runtime-server: stdio MCP transport
# - http-server: HTTP + HTML interface
# - advanced-docx: advanced document operations
RUN cargo build --release --features "runtime-server http-server advanced-docx"
# ============================================================
# Runtime Stage
# ============================================================
FROM debian:bookworm-slim AS runtime
# Install runtime dependencies (including LibreOffice for better PDF conversion)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 \ build-essential \
libfontconfig1 \ libjpeg62-turbo-dev \
libfreetype6 \ libpng-dev \
libjpeg62-turbo \ libfreetype6-dev \
libpng16-16 \ libfontconfig1-dev \
ca-certificates \
libreoffice \ libreoffice \
poppler-utils \ poppler-utils \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Create non-root user # Copy project metadata and source before installing
RUN groupadd -r docxmcp && useradd -r -g docxmcp -s /bin/bash -d /app docxmcp COPY pyproject.toml ./
COPY src ./src
WORKDIR /app # Install Python dependencies (including this package)
RUN chown -R docxmcp:docxmcp /app RUN pip install --upgrade pip && pip install .
# Copy binary from builder # Ensure modules in src are importable at runtime
COPY --from=builder /app/target/release/docx-mcp /usr/local/bin/docx-mcp ENV PYTHONPATH="/app/src"
RUN chmod +x /usr/local/bin/docx-mcp
# Create working directories # Create runtime dirs
RUN mkdir -p /tmp/docx-mcp /templates /out && \ RUN mkdir -p /templates /out /tmp/py-docx-mcp
chown -R docxmcp:docxmcp /tmp/docx-mcp /templates /out
# Switch to non-root user # Environment
USER docxmcp ENV DOCX_MCP_HTTP_HOST=0.0.0.0 \
DOCX_MCP_HTTP_PORT=3000 \
DOCX_MCP_TEMPLATES_DIR=/templates \
DOCX_MCP_MAX_SIZE=104857600 \
DOCX_MCP_MAX_DOCS=30 \
DOCX_MCP_SANDBOX=true \
DOCX_MCP_ALLOW_EXTERNAL_TOOLS=true \
DOCX_MCP_ALLOW_NETWORK=false
# Expose HTTP port (used when running in HTTP mode) # Expose HTTP port (Streamable HTTP for OpenWebUI)
EXPOSE 3000 EXPOSE 3000
# Health check (checks binary is present and executable) # Health check (ensure server module is importable)
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD /usr/local/bin/docx-mcp --version CMD python -c "from server import make_app; print('ok')" || exit 1
# Default environment: # Default: Streamable HTTP for OpenWebUI MCP
# - HTTP disabled by default (use stdio mode). ENTRYPOINT ["python", "-m", "server"]
# - Enable via DOCX_MCP_HTTP=true or --http-mode.
ENV RUST_LOG=info
ENV DOCX_MCP_TEMP=/tmp/docx-mcp
ENV DOCX_MCP_HTTP=false
ENV DOCX_MCP_HTTP_ADDRESS=0.0.0.0:3000
ENV DOCX_MCP_TEMPLATES_DIR=/templates
ENV DOCX_MCP_MAX_SIZE=104857600
ENV DOCX_MCP_MAX_DOCS=30
ENTRYPOINT ["/usr/local/bin/docx-mcp"]
# Default: stdio mode (for MCP clients).
# To run in HTTP mode, override CMD or set DOCX_MCP_HTTP=true.
CMD []
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 DOCX MCP Server Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+40 -1181
View File
File diff suppressed because it is too large Load Diff
-249
View File
@@ -1,249 +0,0 @@
# Release Guide
This document describes the release process for docx-mcp.
## Overview
The release process is automated using GitHub Actions and includes:
- Automated testing on multiple platforms
- Building release binaries for all supported targets
- Publishing to crates.io
- Creating GitHub releases with binaries
- Building and pushing Docker images
- Updating documentation
## Release Types
### Semantic Versioning
We follow [Semantic Versioning](https://semver.org/):
- **MAJOR**: Incompatible API changes
- **MINOR**: New features (backwards compatible)
- **PATCH**: Bug fixes (backwards compatible)
### Pre-release Versions
Pre-release versions can include suffixes like:
- `1.0.0-alpha.1` - Alpha releases
- `1.0.0-beta.1` - Beta releases
- `1.0.0-rc.1` - Release candidates
## Quick Release Process
For most releases, use the automated release script:
```bash
# Patch release (1.0.0 -> 1.0.1)
./scripts/release.sh patch
# Minor release (1.0.0 -> 1.1.0)
./scripts/release.sh minor
# Major release (1.0.0 -> 2.0.0)
./scripts/release.sh major
# Specific version
./scripts/release.sh version 1.5.0
# Pre-release
./scripts/release.sh version 1.0.0-beta.1
```
## Manual Release Process
If you need to create a release manually:
### 1. Pre-release Checks
```bash
# Run all checks
./scripts/release.sh check
# Or manually:
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo build --release --all-features
cargo package --dry-run
```
### 2. Update Version
Update the version in `Cargo.toml`:
```toml
[package]
version = "1.2.3"
```
Update `Cargo.lock`:
```bash
cargo update -p docx-mcp
```
### 3. Commit and Tag
```bash
git add Cargo.toml Cargo.lock
git commit -m "Release v1.2.3"
git tag -a "v1.2.3" -m "Release v1.2.3"
git push origin main
git push origin v1.2.3
```
### 4. GitHub Actions
The release workflow will automatically:
1. Validate the release
2. Run tests on all platforms
3. Build binaries for all targets
4. Create GitHub release
5. Publish to crates.io (stable releases only)
6. Build and push Docker images
7. Update documentation
## Supported Platforms
Release binaries are built for:
- **Linux**: x86_64-unknown-linux-gnu, x86_64-unknown-linux-musl
- **Linux ARM**: aarch64-unknown-linux-gnu, aarch64-unknown-linux-musl
- **macOS**: x86_64-apple-darwin, aarch64-apple-darwin
- **Windows**: x86_64-pc-windows-msvc
## Docker Images
Docker images are published to:
- GitHub Container Registry: `ghcr.io/hongkongkiwi/docx-mcp`
- Docker Hub: `dockerhub-username/docx-mcp` (if configured)
Tags include:
- `latest` - Latest stable release
- `v1.2.3` - Specific version
- `1.2.3` - Semantic version
- `1.2` - Major.minor version
- `1` - Major version
## Publishing to crates.io
Stable releases (without pre-release suffixes) are automatically published to crates.io.
### Prerequisites
1. Set `CARGO_REGISTRY_TOKEN` secret in GitHub repository settings
2. Ensure you have publishing permissions for the crate
### Manual Publishing
```bash
# Dry run
cargo publish --dry-run
# Publish
cargo publish
```
## Troubleshooting
### Release Workflow Fails
1. Check the Actions tab in GitHub for detailed logs
2. Common issues:
- Version mismatch between tag and Cargo.toml
- Tests failing on specific platforms
- Missing secrets (CARGO_REGISTRY_TOKEN, DOCKERHUB credentials)
### Version Already Exists
If you need to recreate a release:
1. Delete the tag: `git tag -d v1.2.3 && git push origin :v1.2.3`
2. Delete the GitHub release (if created)
3. Create the tag again
### Docker Build Fails
1. Check if all dependencies are available in the Docker environment
2. Verify Dockerfile syntax and build context
3. Test locally: `docker build -t docx-mcp:test .`
### crates.io Publishing Fails
1. Verify `CARGO_REGISTRY_TOKEN` is set and valid
2. Check if version already exists
3. Ensure all required metadata is in Cargo.toml
4. Run `cargo package --dry-run` to check for issues
## Security Considerations
### Signing Releases
Currently, releases are not cryptographically signed. Consider adding:
1. GPG signing of Git tags
2. Binary signing with platform-specific tools
3. SBOM (Software Bill of Materials) generation
### Supply Chain Security
- Dependencies are audited in CI with `cargo audit`
- Docker images use specific base image versions
- Build reproducibility is enhanced with Rust's deterministic builds
## Release Checklist
Use this checklist for important releases:
- [ ] All planned features are implemented
- [ ] All tests pass locally and in CI
- [ ] Documentation is updated
- [ ] Breaking changes are documented
- [ ] Migration guide is provided (for major releases)
- [ ] Security implications are reviewed
- [ ] Performance regression tests pass
- [ ] Cross-platform compatibility verified
- [ ] Release notes are prepared
## Post-Release Tasks
After a release:
1. **Verify Installation**: Test installation from released binaries
2. **Update Examples**: Update example configurations if needed
3. **Notify Users**: Announce significant releases
4. **Monitor Issues**: Watch for issues related to the new release
5. **Update Dependencies**: Consider updating dependent projects
## Emergency Releases
For critical security fixes:
1. Create a hotfix branch from the affected release tag
2. Apply minimal fix
3. Follow expedited release process
4. Consider yanking affected versions from crates.io if necessary
```bash
# Yank a version from crates.io (if needed)
cargo yank --version 1.2.3
# Un-yank if needed later
cargo yank --version 1.2.3 --undo
```
## Release Schedule
- **Patch releases**: As needed for bug fixes
- **Minor releases**: Monthly or when significant features accumulate
- **Major releases**: Annually or when breaking changes are necessary
## Getting Help
- Open an issue for release-related problems
- Check GitHub Actions logs for CI failures
- Review this guide and workflow files for automation details
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-553
View File
@@ -1,553 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DOCX MCP Server - Web Interface</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
}
.header {
background: #1a73e8;
color: white;
padding: 1rem 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 1.5rem;
font-weight: 500;
}
.header p {
font-size: 0.875rem;
opacity: 0.9;
margin-top: 0.25rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.panel {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.panel h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: #1a73e8;
}
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.tool-card {
border: 1px solid #ddd;
border-radius: 6px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.tool-card:hover {
border-color: #1a73e8;
box-shadow: 0 2px 8px rgba(26, 115, 232, 0.2);
transform: translateY(-2px);
}
.tool-card h3 {
font-size: 1rem;
color: #1a73e8;
margin-bottom: 0.5rem;
}
.tool-card p {
font-size: 0.875rem;
color: #666;
line-height: 1.4;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: 0.25rem;
color: #555;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.875rem;
}
.form-group textarea {
min-height: 200px;
font-family: monospace;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary {
background: #1a73e8;
color: white;
}
.btn-primary:hover {
background: #1557b0;
}
.btn-secondary {
background: #f1f1f1;
color: #333;
}
.btn-secondary:hover {
background: #ddd;
}
.response-panel {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 1rem;
margin-top: 1rem;
max-height: 400px;
overflow: auto;
}
.response-panel pre {
margin: 0;
white-space: pre-wrap;
font-family: monospace;
font-size: 0.875rem;
}
.status {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.status.success {
background: #d4edda;
color: #155724;
}
.status.error {
background: #f8d7da;
color: #721c24;
}
.status.loading {
background: #fff3cd;
color: #856404;
}
.hidden {
display: none;
}
.connection-status {
position: fixed;
bottom: 1rem;
right: 1rem;
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.connection-status.connected {
background: #d4edda;
color: #155724;
}
.connection-status.disconnected {
background: #f8d7da;
color: #721c24;
}
</style>
</head>
<body>
<div class="header">
<h1>DOCX MCP Server</h1>
<p>Word Document Processing Interface</p>
</div>
<div class="container">
<div class="panel">
<h2>Templates</h2>
<div id="templatesPanel">
<p>Loading templates...</p>
</div>
</div>
<div class="panel">
<h2>Available Tools</h2>
<div class="tool-grid" id="toolGrid">
<div style="text-align: center; padding: 2rem;">
<p>Loading tools...</p>
</div>
</div>
</div>
<div class="panel" id="toolFormPanel" style="display: none;">
<h2 id="toolName">Tool Name</h2>
<p id="toolDescription" style="margin-bottom: 1rem; color: #666;"></p>
<div id="toolForm">
<!-- Form fields will be generated here -->
</div>
<div style="margin-top: 1rem;">
<button class="btn btn-primary" onclick="executeTool()">Execute</button>
<button class="btn btn-secondary" onclick="resetForm()">Reset</button>
</div>
</div>
<div class="panel" id="responsePanel" style="display: none;">
<h2>Response</h2>
<div id="responseStatus"></div>
<div class="response-panel">
<pre id="responseContent"></pre>
</div>
</div>
</div>
<div class="connection-status" id="connectionStatus">
Connecting...
</div>
<script>
let currentTool = null;
let tools = [];
let ws = null;
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
loadTools();
loadTemplates();
connectWebSocket();
});
// Load available templates
async function loadTemplates() {
const container = document.getElementById('templatesPanel');
try {
const response = await fetch('/api/call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'list_templates', arguments: {} })
});
const data = await response.json();
if (!data.success || !data.content || !data.content.templates || data.content.templates.length === 0) {
container.innerHTML = '<p>No templates available.</p>';
return;
}
const list = document.createElement('div');
list.style.display = 'flex';
list.style.flexWrap = 'wrap';
list.style.gap = '0.5rem';
data.content.templates.forEach(t => {
const btn = document.createElement('button');
btn.className = 'btn btn-secondary';
btn.textContent = t;
btn.onclick = () => openTemplate(t);
list.appendChild(btn);
});
container.appendChild(list);
} catch (err) {
container.innerHTML = '<p>Failed to load templates.</p>';
}
}
// Open a template via the server
async function openTemplate(name) {
const responsePanel = document.getElementById('responsePanel');
const status = document.getElementById('responseStatus');
const content = document.getElementById('responseContent');
responsePanel.style.display = 'block';
status.innerHTML = '<span class="status loading">Opening template...</span>';
content.textContent = '';
try {
const res = await fetch('/api/call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'open_template', arguments: { name } })
});
const data = await res.json();
if (data.success) {
status.innerHTML = '<span class="status success">Template opened</span>';
content.textContent = JSON.stringify(data.content, null, 2);
} else {
status.innerHTML = '<span class="status error">Error</span>';
content.textContent = data.error || JSON.stringify(data, null, 2);
}
} catch (err) {
status.innerHTML = '<span class="status error">Error</span>';
content.textContent = err.message;
}
}
// Load available tools
async function loadTools() {
try {
const response = await fetch('/api/tools');
const data = await response.json();
if (data.success) {
tools = data.tools;
renderToolGrid();
}
} catch (error) {
console.error('Failed to load tools:', error);
}
}
// Render tool cards
function renderToolGrid() {
const grid = document.getElementById('toolGrid');
grid.innerHTML = '';
tools.forEach(tool => {
const card = document.createElement('div');
card.className = 'tool-card';
card.onclick = () => selectTool(tool);
card.innerHTML = `
<h3>${tool.name}</h3>
<p>${tool.description || 'No description available'}</p>
`;
grid.appendChild(card);
});
}
// Select a tool to use
function selectTool(tool) {
currentTool = tool;
document.getElementById('toolName').textContent = tool.name;
document.getElementById('toolDescription').textContent = tool.description;
// Generate form based on input schema
generateForm(tool.input_schema);
document.getElementById('toolFormPanel').style.display = 'block';
document.getElementById('responsePanel').style.display = 'none';
}
// Generate form fields from schema
function generateForm(schema) {
const form = document.getElementById('toolForm');
form.innerHTML = '';
if (!schema.properties) return;
Object.entries(schema.properties).forEach(([name, prop]) => {
const group = document.createElement('div');
group.className = 'form-group';
const label = document.createElement('label');
label.textContent = `${name}${schema.required && schema.required.includes(name) ? ' *' : ''}`;
let input;
switch (prop.type) {
case 'string':
if (prop.enum) {
input = document.createElement('select');
input.id = `field_${name}`;
input.innerHTML = '<option value="">Select...</option>';
prop.enum.forEach(option => {
const opt = document.createElement('option');
opt.value = option;
opt.textContent = option;
input.appendChild(opt);
});
} else {
input = document.createElement('textarea');
input.id = `field_${name}`;
input.placeholder = prop.description || `Enter ${name}`;
}
break;
case 'boolean':
input = document.createElement('input');
input.type = 'checkbox';
input.id = `field_${name}`;
break;
case 'number':
case 'integer':
input = document.createElement('input');
input.type = 'number';
input.id = `field_${name}`;
input.placeholder = prop.description || `Enter ${name}`;
break;
case 'array':
case 'object':
input = document.createElement('textarea');
input.id = `field_${name}`;
input.placeholder = prop.description || `Enter JSON for ${name}`;
input.style.fontFamily = 'monospace';
break;
default:
input = document.createElement('input');
input.id = `field_${name}`;
input.placeholder = prop.description || `Enter ${name}`;
}
group.appendChild(label);
group.appendChild(input);
form.appendChild(group);
});
}
// Execute tool call
async function executeTool() {
if (!currentTool) return;
const status = document.getElementById('responseStatus');
const content = document.getElementById('responseContent');
status.innerHTML = '<span class="status loading">Executing...</span>';
content.textContent = '';
document.getElementById('responsePanel').style.display = 'block';
// Collect form data
const arguments = {};
const schema = currentTool.input_schema;
if (schema.properties) {
Object.entries(schema.properties).forEach(([name, prop]) => {
const field = document.getElementById(`field_${name}`);
if (field) {
let value;
switch (prop.type) {
case 'boolean':
value = field.checked;
break;
case 'number':
case 'integer':
value = parseInt(field.value) || field.value;
break;
case 'array':
case 'object':
try {
value = JSON.parse(field.value);
} catch {
value = field.value;
}
break;
default:
value = field.value;
}
if (value || value === false) {
arguments[name] = value;
}
}
});
}
try {
const response = await fetch('/api/call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: currentTool.name,
arguments: arguments
})
});
const data = await response.json();
if (data.success) {
status.innerHTML = '<span class="status success">Success</span>';
content.textContent = JSON.stringify(data.content, null, 2);
} else {
status.innerHTML = '<span class="status error">Error</span>';
content.textContent = data.error || 'Unknown error occurred';
}
} catch (error) {
status.innerHTML = '<span class="status error">Error</span>';
content.textContent = error.message;
}
}
// Reset form
function resetForm() {
const fields = document.querySelectorAll('#toolForm input, #toolForm textarea, #toolForm select');
fields.forEach(field => {
if (field.type === 'checkbox') {
field.checked = false;
} else {
field.value = '';
}
});
}
// WebSocket connection for real-time updates
function connectWebSocket() {
const status = document.getElementById('connectionStatus');
try {
ws = new WebSocket(`ws://${window.location.host}/ws`);
ws.onopen = () => {
status.textContent = 'Connected';
status.className = 'connection-status connected';
};
ws.onclose = () => {
status.textContent = 'Disconnected';
status.className = 'connection-status disconnected';
setTimeout(connectWebSocket, 5000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
status.textContent = 'Connection Error';
status.className = 'connection-status disconnected';
};
} catch (error) {
console.error('Failed to connect WebSocket:', error);
}
}
</script>
</body>
</html>
-456
View File
@@ -1,456 +0,0 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
use docx_mcp::pure_converter::PureRustConverter;
use tempfile::TempDir;
use std::time::Duration;
fn setup_handler() -> (DocxHandler, TempDir) {
let temp_dir = TempDir::new().unwrap();
let handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
(handler, temp_dir)
}
fn bench_document_creation(c: &mut Criterion) {
c.bench_function("create_document", |b| {
b.iter_batched(
|| setup_handler(),
|(mut handler, _temp_dir)| {
black_box(handler.create_document().unwrap())
},
criterion::BatchSize::LargeInput,
)
});
}
fn bench_paragraph_addition(c: &mut Criterion) {
let mut group = c.benchmark_group("add_paragraph");
for paragraph_count in [1, 10, 100, 1000].iter() {
group.bench_with_input(
BenchmarkId::new("count", paragraph_count),
paragraph_count,
|b, &count| {
b.iter_batched(
|| {
let (mut handler, temp_dir) = setup_handler();
let doc_id = handler.create_document().unwrap();
(handler, doc_id, temp_dir)
},
|(mut handler, doc_id, _temp_dir)| {
for i in 0..count {
let text = format!("This is paragraph number {} with some content", i);
handler.add_paragraph(&doc_id, &text, None).unwrap();
}
black_box(doc_id)
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_styled_paragraph_addition(c: &mut Criterion) {
c.bench_function("add_styled_paragraph", |b| {
b.iter_batched(
|| {
let (mut handler, temp_dir) = setup_handler();
let doc_id = handler.create_document().unwrap();
let style = DocxStyle {
font_family: Some("Arial".to_string()),
font_size: Some(12),
bold: Some(true),
italic: Some(false),
underline: Some(false),
color: Some("#000000".to_string()),
alignment: Some("left".to_string()),
line_spacing: Some(1.0),
};
(handler, doc_id, temp_dir, style)
},
|(mut handler, doc_id, _temp_dir, style)| {
black_box(handler.add_paragraph(&doc_id, "Styled paragraph", Some(style)).unwrap())
},
criterion::BatchSize::LargeInput,
)
});
}
fn bench_heading_addition(c: &mut Criterion) {
let mut group = c.benchmark_group("add_heading");
for level in 1..=6 {
group.bench_with_input(
BenchmarkId::new("level", level),
&level,
|b, &level| {
b.iter_batched(
|| {
let (mut handler, temp_dir) = setup_handler();
let doc_id = handler.create_document().unwrap();
(handler, doc_id, temp_dir)
},
|(mut handler, doc_id, _temp_dir)| {
black_box(handler.add_heading(&doc_id, &format!("Heading Level {}", level), level).unwrap())
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_table_addition(c: &mut Criterion) {
let mut group = c.benchmark_group("add_table");
for size in [(2, 2), (5, 5), (10, 10), (20, 10)].iter() {
group.bench_with_input(
BenchmarkId::new("size", format!("{}x{}", size.0, size.1)),
size,
|b, &(rows, cols)| {
b.iter_batched(
|| {
let (mut handler, temp_dir) = setup_handler();
let doc_id = handler.create_document().unwrap();
let mut table_rows = Vec::new();
for i in 0..rows {
let mut row = Vec::new();
for j in 0..cols {
row.push(format!("Cell {}x{}", i, j));
}
table_rows.push(row);
}
let table_data = TableData {
rows: table_rows,
headers: None,
border_style: Some("single".to_string()),
};
(handler, doc_id, temp_dir, table_data)
},
|(mut handler, doc_id, _temp_dir, table_data)| {
black_box(handler.add_table(&doc_id, table_data).unwrap())
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_list_addition(c: &mut Criterion) {
let mut group = c.benchmark_group("add_list");
for item_count in [5, 20, 50, 100].iter() {
group.bench_with_input(
BenchmarkId::new("items", item_count),
item_count,
|b, &count| {
b.iter_batched(
|| {
let (mut handler, temp_dir) = setup_handler();
let doc_id = handler.create_document().unwrap();
let items: Vec<String> = (0..count)
.map(|i| format!("List item number {}", i))
.collect();
(handler, doc_id, temp_dir, items)
},
|(mut handler, doc_id, _temp_dir, items)| {
black_box(handler.add_list(&doc_id, items, false).unwrap())
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_text_extraction(c: &mut Criterion) {
let mut group = c.benchmark_group("extract_text");
for paragraph_count in [10, 100, 500, 1000].iter() {
group.bench_with_input(
BenchmarkId::new("paragraphs", paragraph_count),
paragraph_count,
|b, &count| {
b.iter_batched(
|| {
let (mut handler, temp_dir) = setup_handler();
let doc_id = handler.create_document().unwrap();
// Create document with many paragraphs
for i in 0..count {
let text = format!("This is paragraph {} with substantial content to test text extraction performance. It includes various words and punctuation to make it realistic.", i);
handler.add_paragraph(&doc_id, &text, None).unwrap();
}
(handler, doc_id, temp_dir)
},
|(handler, doc_id, _temp_dir)| {
black_box(handler.extract_text(&doc_id).unwrap())
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_pdf_conversion(c: &mut Criterion) {
let mut group = c.benchmark_group("pdf_conversion");
group.measurement_time(Duration::from_secs(30)); // Longer measurement for PDF conversion
for paragraph_count in [10, 50, 200].iter() {
group.bench_with_input(
BenchmarkId::new("paragraphs", paragraph_count),
paragraph_count,
|b, &count| {
b.iter_batched(
|| {
let (mut handler, temp_dir) = setup_handler();
let doc_id = handler.create_document().unwrap();
// Create substantial document content
handler.add_heading(&doc_id, "Performance Test Document", 1).unwrap();
for i in 0..count {
if i % 20 == 0 {
handler.add_heading(&doc_id, &format!("Section {}", i / 20 + 1), 2).unwrap();
}
let text = format!("This is paragraph {} designed to test PDF conversion performance. It contains enough text to make the conversion meaningful and test the system under realistic load conditions.", i);
handler.add_paragraph(&doc_id, &text, None).unwrap();
}
let metadata = handler.get_metadata(&doc_id).unwrap();
let converter = PureRustConverter::new();
let output_path = temp_dir.path().join("benchmark.pdf");
(metadata, converter, output_path, temp_dir)
},
|(metadata, converter, output_path, _temp_dir)| {
black_box(converter.convert_docx_to_pdf(&metadata.path, &output_path).unwrap())
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_image_conversion(c: &mut Criterion) {
let mut group = c.benchmark_group("image_conversion");
group.measurement_time(Duration::from_secs(45)); // Even longer for image conversion
for paragraph_count in [5, 20, 50].iter() {
group.bench_with_input(
BenchmarkId::new("paragraphs", paragraph_count),
paragraph_count,
|b, &count| {
b.iter_batched(
|| {
let (mut handler, temp_dir) = setup_handler();
let doc_id = handler.create_document().unwrap();
handler.add_heading(&doc_id, "Image Conversion Test", 1).unwrap();
for i in 0..count {
let text = format!("Paragraph {} for image conversion testing.", i);
handler.add_paragraph(&doc_id, &text, None).unwrap();
}
let metadata = handler.get_metadata(&doc_id).unwrap();
let converter = PureRustConverter::new();
let output_dir = temp_dir.path().join("images");
std::fs::create_dir_all(&output_dir).unwrap();
(metadata, converter, output_dir, temp_dir)
},
|(metadata, converter, output_dir, _temp_dir)| {
black_box(converter.convert_docx_to_images(&metadata.path, &output_dir).unwrap())
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_concurrent_operations(c: &mut Criterion) {
let mut group = c.benchmark_group("concurrent_operations");
for thread_count in [2, 4, 8].iter() {
group.bench_with_input(
BenchmarkId::new("threads", thread_count),
thread_count,
|b, &threads| {
b.iter_batched(
|| {
let temp_dir = TempDir::new().unwrap();
(temp_dir, threads)
},
|(temp_dir, thread_count)| {
use std::sync::Arc;
use std::thread;
let temp_path = Arc::new(temp_dir.path().to_path_buf());
let handles: Vec<_> = (0..thread_count).map(|i| {
let temp_path = Arc::clone(&temp_path);
thread::spawn(move || {
let mut handler = DocxHandler::new_with_temp_dir(&temp_path).unwrap();
let doc_id = handler.create_document().unwrap();
for j in 0..10 {
let text = format!("Thread {} paragraph {}", i, j);
handler.add_paragraph(&doc_id, &text, None).unwrap();
}
handler.extract_text(&doc_id).unwrap()
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
black_box(())
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_memory_usage(c: &mut Criterion) {
let mut group = c.benchmark_group("memory_usage");
for doc_count in [5, 20, 50].iter() {
group.bench_with_input(
BenchmarkId::new("documents", doc_count),
doc_count,
|b, &count| {
b.iter_batched(
|| setup_handler(),
|(mut handler, _temp_dir)| {
let mut doc_ids = Vec::new();
// Create multiple documents
for i in 0..count {
let doc_id = handler.create_document().unwrap();
// Add content to each document
handler.add_heading(&doc_id, &format!("Document {}", i), 1).unwrap();
for j in 0..20 {
let text = format!("Content paragraph {} in document {}", j, i);
handler.add_paragraph(&doc_id, &text, None).unwrap();
}
doc_ids.push(doc_id);
}
// Extract text from all documents
for doc_id in &doc_ids {
handler.extract_text(doc_id).unwrap();
}
black_box(doc_ids)
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_complex_document_operations(c: &mut Criterion) {
c.bench_function("complex_document", |b| {
b.iter_batched(
|| setup_handler(),
|(mut handler, _temp_dir)| {
let doc_id = handler.create_document().unwrap();
// Create a complex document with all features
handler.add_heading(&doc_id, "Complex Document Test", 1).unwrap();
handler.add_paragraph(&doc_id, "This is a comprehensive test document.", None).unwrap();
// Add styled paragraph
let style = DocxStyle {
font_size: Some(14),
bold: Some(true),
color: Some("#FF0000".to_string()),
alignment: Some("center".to_string()),
..Default::default()
};
handler.add_paragraph(&doc_id, "Styled paragraph", Some(style)).unwrap();
// Add table
let table_data = TableData {
rows: vec![
vec!["Header 1".to_string(), "Header 2".to_string(), "Header 3".to_string()],
vec!["Row 1 Col 1".to_string(), "Row 1 Col 2".to_string(), "Row 1 Col 3".to_string()],
vec!["Row 2 Col 1".to_string(), "Row 2 Col 2".to_string(), "Row 2 Col 3".to_string()],
],
headers: Some(vec!["Header 1".to_string(), "Header 2".to_string(), "Header 3".to_string()]),
border_style: Some("single".to_string()),
};
handler.add_table(&doc_id, table_data).unwrap();
// Add list
let items = vec![
"First item".to_string(),
"Second item".to_string(),
"Third item".to_string(),
];
handler.add_list(&doc_id, items, true).unwrap();
// Add page break and more content
handler.add_page_break(&doc_id).unwrap();
handler.add_heading(&doc_id, "Second Page", 1).unwrap();
handler.add_paragraph(&doc_id, "Content on second page", None).unwrap();
// Set header and footer
handler.set_header(&doc_id, "Document Header").unwrap();
handler.set_footer(&doc_id, "Document Footer").unwrap();
// Extract all text
let text = handler.extract_text(&doc_id).unwrap();
black_box(text)
},
criterion::BatchSize::LargeInput,
)
});
}
criterion_group!(
benches,
bench_document_creation,
bench_paragraph_addition,
bench_styled_paragraph_addition,
bench_heading_addition,
bench_table_addition,
bench_list_addition,
bench_text_extraction,
bench_pdf_conversion,
bench_image_conversion,
bench_concurrent_operations,
bench_memory_usage,
bench_complex_document_operations
);
criterion_main!(benches);
-37
View File
@@ -1,37 +0,0 @@
use anyhow::Result;
use std::fs;
use std::path::Path;
fn main() -> Result<()> {
println!("cargo:rerun-if-changed=build.rs");
// Create assets directory if it doesn't exist
let fonts_dir = Path::new("assets/fonts");
fs::create_dir_all(fonts_dir)?;
// Check if fonts exist, if not, create placeholder files
// In production, you would download actual font files here
let font_files = vec![
"LiberationSans-Regular.ttf",
"LiberationSans-Bold.ttf",
"LiberationSans-Italic.ttf",
"LiberationMono-Regular.ttf",
"NotoSans-Regular.ttf",
"NotoSans-Bold.ttf",
];
for font_file in font_files {
let font_path = fonts_dir.join(font_file);
if !font_path.exists() {
// For now, we'll create empty placeholder files
// In production, download actual Liberation or Noto fonts (which are open source)
println!("cargo:warning=Font file {} not found. Please download Liberation fonts from https://github.com/liberationfonts/liberation-fonts", font_file);
// Create a minimal placeholder TTF file (this won't work for actual rendering)
// You should download the actual fonts
fs::write(&font_path, &[0u8; 100])?;
}
}
Ok(())
}
-107
View File
@@ -1,107 +0,0 @@
#!/bin/bash
# Build script for DOCX MCP Server
set -e
echo "🔨 Building DOCX MCP Server (Standalone Edition)..."
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Check for Rust
if ! command -v cargo &> /dev/null; then
echo -e "${RED}❌ Cargo not found. Please install Rust.${NC}"
echo "Visit: https://www.rust-lang.org/tools/install"
exit 1
fi
# Check if fonts are downloaded
if [ ! -f "assets/fonts/LiberationSans-Regular.ttf" ]; then
echo -e "${YELLOW}📥 Fonts not found. Downloading open-source fonts...${NC}"
if [ -f "./download_fonts.sh" ]; then
./download_fonts.sh
else
echo -e "${YELLOW}⚠️ Font files not found. The server will still work but with basic fonts.${NC}"
echo -e "${YELLOW} Run ./download_fonts.sh to download professional fonts.${NC}"
mkdir -p assets/fonts
# Create placeholder files so build doesn't fail
touch assets/fonts/LiberationSans-Regular.ttf
touch assets/fonts/LiberationSans-Bold.ttf
touch assets/fonts/LiberationSans-Italic.ttf
touch assets/fonts/LiberationMono-Regular.ttf
touch assets/fonts/NotoSans-Regular.ttf
touch assets/fonts/NotoSans-Bold.ttf
fi
fi
# Build mode selection
BUILD_MODE=${1:-release}
FEATURES=${2:-}
if [ "$BUILD_MODE" = "debug" ]; then
echo -e "${YELLOW}📦 Building in debug mode...${NC}"
if [ -n "$FEATURES" ]; then
cargo build --features "$FEATURES"
else
cargo build
fi
BINARY_PATH="target/debug/docx-mcp"
else
echo -e "${YELLOW}📦 Building in release mode...${NC}"
if [ -n "$FEATURES" ]; then
cargo build --release --features "$FEATURES"
else
cargo build --release
fi
BINARY_PATH="target/release/docx-mcp"
fi
# Check if build succeeded
if [ -f "$BINARY_PATH" ]; then
echo -e "${GREEN}✅ Build successful!${NC}"
echo -e "Binary location: ${GREEN}$BINARY_PATH${NC}"
# Display standalone features
echo -e "\n${BLUE}🎯 Standalone Features Enabled:${NC}"
echo -e "${GREEN}${NC} Pure Rust DOCX parsing"
echo -e "${GREEN}${NC} Built-in PDF generation"
echo -e "${GREEN}${NC} Embedded fonts"
echo -e "${GREEN}${NC} Native image processing"
echo -e "${GREEN}${NC} Zero external dependencies required"
# Check for optional enhancements
echo -e "\n${YELLOW}Optional enhancements (not required):${NC}"
if command -v libreoffice &> /dev/null; then
echo -e "${GREEN}${NC} LibreOffice found (enhanced PDF conversion available)"
else
echo -e "${YELLOW}${NC} LibreOffice not found (using built-in PDF converter)"
echo " Optional: brew install libreoffice (macOS) or apt-get install libreoffice (Linux)"
fi
if command -v pdftoppm &> /dev/null; then
echo -e "${GREEN}${NC} pdftoppm found (PDF to image conversion available)"
elif command -v convert &> /dev/null; then
echo -e "${GREEN}${NC} ImageMagick found (PDF to image conversion available)"
elif command -v gs &> /dev/null; then
echo -e "${GREEN}${NC} Ghostscript found (PDF to image conversion available)"
else
echo -e "${YELLOW}${NC} No PDF to image converter found"
echo " Install one of: poppler-utils, imagemagick, or ghostscript"
fi
# Create example output directories
mkdir -p example/output example/images example/thumbnails
echo -e "\n${GREEN}🚀 Ready to run!${NC}"
echo -e "Start the server with: ${GREEN}$BINARY_PATH${NC}"
echo -e "Or with logging: ${GREEN}RUST_LOG=info $BINARY_PATH${NC}"
else
echo -e "${RED}❌ Build failed!${NC}"
exit 1
fi
-107
View File
@@ -1,107 +0,0 @@
# cargo-deny configuration for dependency management and security
[graph]
targets = [
{ triple = "x86_64-unknown-linux-gnu" },
{ triple = "x86_64-pc-windows-msvc" },
{ triple = "x86_64-apple-darwin" },
{ triple = "aarch64-apple-darwin" },
]
[advisories]
# The path where the advisory database is cloned/fetched into
db-path = "~/.cargo/advisory-db"
# The url(s) of the advisory databases to use
db-urls = ["https://github.com/rustsec/advisory-db"]
# The lint level for security vulnerabilities
vulnerability = "deny"
# The lint level for unmaintained crates
unmaintained = "warn"
# The lint level for crates that have been yanked from their source registry
yanked = "warn"
# The lint level for crates with security notices
notice = "warn"
# A list of advisory IDs to ignore. Note that ignored advisories will still
# output a note when they are encountered.
ignore = [
#"RUSTSEC-0000-0000",
]
[licenses]
# The confidence threshold for detecting a license from a license text.
confidence-threshold = 0.8
# List of explicitly allowed licenses
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-DFS-2016",
]
# List of explicitly disallowed licenses
deny = [
"GPL-2.0",
"GPL-3.0",
"AGPL-3.0",
]
# Lint level for when multiple versions of the same license are detected
copyleft = "warn"
# Some crates don't have license files and we allow them specifically
exceptions = [
# Allow ring which has some complex licensing
{ allow = ["MIT", "ISC", "OpenSSL"], name = "ring" },
# webpki has Mozilla's license
{ allow = ["ISC", "MIT", "MPL-2.0"], name = "webpki" },
]
[[licenses.clarify]]
name = "ring"
# SPDX identifier
expression = "MIT AND ISC AND OpenSSL"
# License file paths
license-files = [
{ path = "LICENSE", hash = 0xbd0eed23 }
]
[bans]
# Lint level for when multiple versions of the same crate are detected
multiple-versions = "warn"
# Lint level for when a crate version requirement is `*`
wildcards = "allow"
# The graph highlighting used when creating dotgraphs for crates
highlight = "all"
# List of crates that are allowed. Use with care!
allow = [
#{ name = "ansi_term", version = "=0.11.0" },
]
# List of crates to deny
deny = [
# Insecure random number generation
{ name = "openssl", version = "*", use-instead = "rustls" },
# Unmaintained and insecure
{ name = "chrono", version = "<0.4.20" },
]
# Certain crates/versions that will be skipped when doing duplicate detection.
skip = [
#{ name = "ansi_term", version = "=0.11.0" },
]
# Similarly to `skip` allows you to skip certain crates from being checked. Unlike
# `skip`, a skipped crate is removed from the crate graph entirely.
skip-tree = [
#{ name = "ansi_term", version = "=0.11.0", depth = 20 },
]
[sources]
# Lint level for what to happen when a crate from a crate registry that is
# not in the allow list is encountered
unknown-registry = "warn"
# Lint level for what to happen when a crate from a git repository that is not
# in the allow list is encountered
unknown-git = "warn"
# List of URLs for allowed crate registries. Defaults to the crates.io index
# if not specified. If it is specified but empty, no registries are allowed.
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
# List of URLs for allowed Git repositories
allow-git = []
-64
View File
@@ -1,64 +0,0 @@
version: '3.8'
# Production deployment for docx-mcp server
# This creates a sandboxed environment with optional WebSocket bridge for remote access
services:
# WebSocket bridge for remote access (optional)
# Comment out this service if using local stdio transport only
websockify:
image: websockify/websockify
ports:
- "8080:8080"
depends_on:
- docx-mcp
command: ["--web", "/dev/null", "8080", "docx-mcp:8080"]
networks:
- docx-network
restart: unless-stopped
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "8080"]
interval: 30s
timeout: 5s
retries: 3
# Main docx-mcp server
docx-mcp:
image: docx-mcp:sandboxed
build:
context: .
dockerfile: Dockerfile.sandboxed
read_only: true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # Only if exposing port for bridge
tmpfs:
- /tmp/docx-mcp:rw,noexec,nosuid,size=100m
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
replicas: 1
environment:
- RUST_LOG=info
- DOCX_MCP_SANDBOX=true
- DOCX_MCP_NO_EXTERNAL_TOOLS=true
- DOCX_MCP_NO_NETWORK=true
- DOCX_MCP_MAX_SIZE=52428800
- DOCX_MCP_MAX_DOCS=20
networks:
- docx-network
ports:
- "8080:8080" # Only needed for WebSocket bridge
restart: unless-stopped
healthcheck:
test: ["CMD", "/usr/local/bin/docx-mcp", "--version"]
interval: 30s
timeout: 5s
retries: 3
networks:
docx-network:
driver: bridge
-45
View File
@@ -1,45 +0,0 @@
#!/bin/bash
# Script to download open-source fonts for embedded PDF generation
# These fonts are used when creating PDFs without external dependencies
set -e
FONTS_DIR="assets/fonts"
mkdir -p "$FONTS_DIR"
echo "📥 Downloading open-source fonts for standalone operation..."
# Liberation Fonts (Red Hat) - Open source replacements for Arial, Times New Roman, Courier
LIBERATION_VERSION="2.1.5"
LIBERATION_URL="https://github.com/liberationfonts/liberation-fonts/files/7261482/liberation-fonts-ttf-${LIBERATION_VERSION}.tar.gz"
# Download Liberation fonts
echo "Downloading Liberation fonts..."
curl -L "$LIBERATION_URL" -o /tmp/liberation-fonts.tar.gz
tar -xzf /tmp/liberation-fonts.tar.gz -C /tmp/
# Copy the fonts we need
cp "/tmp/liberation-fonts-ttf-${LIBERATION_VERSION}/LiberationSans-Regular.ttf" "$FONTS_DIR/"
cp "/tmp/liberation-fonts-ttf-${LIBERATION_VERSION}/LiberationSans-Bold.ttf" "$FONTS_DIR/"
cp "/tmp/liberation-fonts-ttf-${LIBERATION_VERSION}/LiberationSans-Italic.ttf" "$FONTS_DIR/"
cp "/tmp/liberation-fonts-ttf-${LIBERATION_VERSION}/LiberationMono-Regular.ttf" "$FONTS_DIR/"
# Noto Sans (Google) - Fallback font with wide Unicode coverage
echo "Downloading Noto Sans fonts..."
NOTO_BASE_URL="https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSans"
curl -L "${NOTO_BASE_URL}/NotoSans-Regular.ttf" -o "$FONTS_DIR/NotoSans-Regular.ttf"
curl -L "${NOTO_BASE_URL}/NotoSans-Bold.ttf" -o "$FONTS_DIR/NotoSans-Bold.ttf"
# Clean up
rm -f /tmp/liberation-fonts.tar.gz
rm -rf /tmp/liberation-fonts-ttf-${LIBERATION_VERSION}
echo "✅ Fonts downloaded successfully!"
echo ""
echo "Fonts installed in $FONTS_DIR:"
ls -la "$FONTS_DIR"/*.ttf
echo ""
echo "The application can now run completely standalone without external dependencies!"
Submodule example/MCP-Doc deleted from 377d05f0a9
-492
View File
@@ -1,492 +0,0 @@
# Advanced DOCX MCP Server Usage Examples
This document demonstrates the advanced capabilities of the DOCX MCP server with real-world examples.
## Professional Document Templates
### Creating a Business Report
```javascript
// Ask your AI: "Create a professional quarterly report with our sales data"
// 1. Create from report template
const doc = await mcp.call("create_from_template", {
template: "Report"
});
// 2. Set document properties
await mcp.call("set_document_properties", {
document_id: doc.document_id,
properties: {
title: "Q3 2024 Sales Report",
subject: "Quarterly Business Review",
author: "Sales Team",
company: "TechCorp Inc",
keywords: ["sales", "quarterly", "2024", "revenue"]
}
});
// 3. Add custom sections with advanced formatting
await mcp.call("add_section", {
document_id: doc.document_id,
section_config: {
page_size: "Letter",
landscape: false,
margins: {
top: 25.4,
bottom: 25.4,
left: 31.8,
right: 25.4
},
columns: 1
}
});
// 4. Add charts and data visualization
await mcp.call("add_chart", {
document_id: doc.document_id,
chart_type: "Column",
data: {
title: "Quarterly Revenue Growth",
categories: ["Q1", "Q2", "Q3"],
series: [{
name: "Revenue ($M)",
values: [1.2, 1.5, 1.8]
}]
}
});
```
### Advanced Mail Merge Campaign
```javascript
// Ask your AI: "Create personalized letters for our client list with custom fields"
// 1. Create template with merge fields
const template = await mcp.call("create_from_template", {
template: "BusinessLetter"
});
await mcp.call("prepare_mail_merge_template", {
document_id: template.document_id,
fields: ["ClientName", "Company", "LastOrderDate", "AccountManager", "SpecialOffer"]
});
// 2. Process each recipient
const recipients = [
{
ClientName: "John Smith",
Company: "ABC Corp",
LastOrderDate: "2024-02-15",
AccountManager: "Sarah Johnson",
SpecialOffer: "20% off next order"
}
// ... more recipients
];
for (const recipient of recipients) {
// Create personalized document
const personalDoc = await mcp.call("merge_template", {
template_id: template.document_id,
data: recipient
});
// Add watermark for draft review
await mcp.call("add_watermark", {
document_id: personalDoc.document_id,
text: "CONFIDENTIAL",
style: "Diagonal"
});
}
```
## Document Analysis & Review
### Comprehensive Document Analysis
```javascript
// Ask your AI: "Analyze this contract for structure, formatting, and key terms"
const doc = await mcp.call("open_document", {
path: "./contracts/service_agreement.docx"
});
// 1. Get document structure
const structure = await mcp.call("get_document_structure", {
document_id: doc.document_id
});
// 2. Analyze formatting consistency
const formatting = await mcp.call("analyze_formatting", {
document_id: doc.document_id
});
// 3. Get detailed statistics
const stats = await mcp.call("get_word_count", {
document_id: doc.document_id
});
// 4. Search for key legal terms
const terms = ["liability", "indemnification", "termination", "confidential"];
for (const term of terms) {
const results = await mcp.call("search_text", {
document_id: doc.document_id,
search_term: term,
case_sensitive: false,
whole_word: true
});
console.log(`Found "${term}" ${results.total_matches} times`);
}
// 5. Export analysis to Markdown
await mcp.call("export_to_markdown", {
document_id: doc.document_id,
output_path: "./analysis/contract_analysis.md"
});
```
### Collaborative Review Process
```javascript
// Ask your AI: "Set up this document for review with comments and track changes"
// 1. Enable track changes
await mcp.call("enable_track_changes", {
document_id: doc.document_id,
author: "Legal Review Team"
});
// 2. Add review comments
await mcp.call("add_comment", {
document_id: doc.document_id,
text: "Payment terms in section 3.2",
comment: "Consider reducing payment terms from 60 to 30 days",
author: "Finance Team"
});
// 3. Add footnotes for clarification
await mcp.call("add_footnote", {
document_id: doc.document_id,
reference_text: "governing law",
footnote_text: "This clause should specify the state jurisdiction for legal disputes"
});
// 4. Create bookmarks for easy navigation
await mcp.call("add_bookmark", {
document_id: doc.document_id,
bookmark_name: "payment_terms",
text: "3.2 Payment Terms"
});
// 5. Add cross-references
await mcp.call("add_cross_reference", {
document_id: doc.document_id,
bookmark_name: "payment_terms",
display_text: "See Payment Terms section"
});
```
## Security & Compliance Examples
### Readonly Document Review
```bash
# Start server in readonly mode for document review only
export DOCX_MCP_READONLY=true
./target/release/docx-mcp
```
```javascript
// In readonly mode, these operations are available:
const doc = await mcp.call("open_document", {
path: "./confidential/annual_report.docx"
});
// ✅ Allowed: Extract and analyze content
const text = await mcp.call("extract_text", {
document_id: doc.document_id
});
const structure = await mcp.call("get_document_structure", {
document_id: doc.document_id
});
// ✅ Allowed: Export for analysis
await mcp.call("export_to_markdown", {
document_id: doc.document_id,
output_path: "./analysis/report_content.md"
});
// ❌ Blocked: Any modification attempts
// These would return security errors:
// - add_paragraph
// - save_document
// - find_and_replace
```
### Sandboxed Environment
```bash
# Run in sandbox mode - restricts file operations to temp directory
export DOCX_MCP_SANDBOX=true
export DOCX_MCP_NO_EXTERNAL_TOOLS=true
./target/release/docx-mcp
```
```javascript
// All file operations restricted to temporary directory
// Perfect for untrusted document processing
const doc = await mcp.call("create_document", {});
// ✅ Allowed: Operations in temp directory
await mcp.call("save_document", {
document_id: doc.document_id,
output_path: "/tmp/docx-mcp/safe_output.docx"
});
// ❌ Blocked: Operations outside temp directory
// This would return a security error:
await mcp.call("save_document", {
document_id: doc.document_id,
output_path: "/home/user/documents/output.docx" // BLOCKED
});
```
## Advanced Automation Workflows
### Automated Report Generation Pipeline
```javascript
// Ask your AI: "Create an automated monthly report generation system"
class ReportGenerator {
async generateMonthlyReport(month, year, data) {
// 1. Create from template
const doc = await mcp.call("create_from_template", {
template: "Report"
});
// 2. Set up custom styles
await mcp.call("add_custom_style", {
document_id: doc.document_id,
style: {
id: "CompanyHeading",
name: "Company Heading",
font: "Arial",
size: 18,
bold: true,
color: "#2E86C1",
spacing: {
before: 12,
after: 6,
line: 1.15
}
}
});
// 3. Add dynamic content with bookmarks
await mcp.call("add_bookmark", {
document_id: doc.document_id,
bookmark_name: "executive_summary",
text: "Executive Summary"
});
// 4. Insert data charts
for (const metric of data.metrics) {
await mcp.call("add_chart", {
document_id: doc.document_id,
chart_type: metric.type,
data: {
title: metric.title,
categories: metric.categories,
series: metric.series
}
});
}
// 5. Add table of contents
await mcp.call("add_table_of_contents", {
document_id: doc.document_id
});
// 6. Apply watermark
await mcp.call("add_watermark", {
document_id: doc.document_id,
text: "INTERNAL USE ONLY",
style: "Horizontal"
});
// 7. Generate multiple formats
const filename = `monthly_report_${year}_${month}`;
// Save DOCX
await mcp.call("save_document", {
document_id: doc.document_id,
output_path: `./reports/${filename}.docx`
});
// Convert to PDF
await mcp.call("convert_to_pdf", {
document_id: doc.document_id,
output_path: `./reports/${filename}.pdf`
});
// Generate preview images
await mcp.call("convert_to_images", {
document_id: doc.document_id,
output_dir: `./reports/previews/`,
format: "png",
dpi: 150
});
return {
docx: `./reports/${filename}.docx`,
pdf: `./reports/${filename}.pdf`,
preview: `./reports/previews/`
};
}
}
```
### Document Quality Assurance
```javascript
// Ask your AI: "Create a document QA system that checks formatting and compliance"
class DocumentQA {
async auditDocument(documentPath) {
const doc = await mcp.call("open_document", {
path: documentPath
});
const audit = {
document: documentPath,
timestamp: new Date().toISOString(),
issues: [],
recommendations: []
};
// 1. Check document structure
const structure = await mcp.call("get_document_structure", {
document_id: doc.document_id
});
if (structure.structure.filter(s => s.type === "heading").length < 2) {
audit.issues.push("Document lacks proper heading structure");
}
// 2. Analyze formatting consistency
const formatting = await mcp.call("analyze_formatting", {
document_id: doc.document_id
});
if (formatting.formatting_analysis.fonts_detected.length > 3) {
audit.issues.push("Too many fonts used - limit to 2-3 for consistency");
}
// 3. Check for required content
const requiredTerms = ["confidential", "copyright", "contact"];
for (const term of requiredTerms) {
const search = await mcp.call("search_text", {
document_id: doc.document_id,
search_term: term,
case_sensitive: false
});
if (search.total_matches === 0) {
audit.recommendations.push(`Consider adding ${term} information`);
}
}
// 4. Check document statistics
const stats = await mcp.call("get_word_count", {
document_id: doc.document_id
});
if (stats.statistics.words < 500) {
audit.issues.push("Document may be too short for professional standards");
}
// 5. Generate audit report
const auditDoc = await mcp.call("create_document", {});
await mcp.call("add_heading", {
document_id: auditDoc.document_id,
text: "Document Quality Audit Report",
level: 1
});
await mcp.call("add_paragraph", {
document_id: auditDoc.document_id,
text: `Audit completed for: ${documentPath}`
});
// Add issues table
const issuesData = audit.issues.map(issue => ["Issue", issue]);
await mcp.call("add_table", {
document_id: auditDoc.document_id,
rows: [["Type", "Description"], ...issuesData]
});
await mcp.call("save_document", {
document_id: auditDoc.document_id,
output_path: `./qa/audit_${Date.now()}.docx`
});
return audit;
}
}
```
## Security Configuration Examples
### Enterprise Security Setup
```bash
#!/bin/bash
# Enterprise security configuration script
# Readonly mode for document review workstations
export DOCX_MCP_READONLY=true
# Whitelist only analysis and export commands
export DOCX_MCP_WHITELIST="open_document,extract_text,get_metadata,get_document_structure,analyze_formatting,get_word_count,search_text,export_to_markdown,export_to_html,list_documents,get_security_info"
# Sandbox mode for processing untrusted documents
export DOCX_MCP_SANDBOX=true
# Resource limits
export DOCX_MCP_MAX_SIZE=10485760 # 10MB max file size
export DOCX_MCP_MAX_DOCS=5 # Max 5 open documents
# Disable external tools and network
export DOCX_MCP_NO_EXTERNAL_TOOLS=true
export DOCX_MCP_NO_NETWORK=true
echo "🔒 Starting DOCX MCP Server in Enterprise Security Mode"
./target/release/docx-mcp
```
### Development Environment Setup
```bash
#!/bin/bash
# Development environment with full features
# Allow all operations but with reasonable limits
export DOCX_MCP_MAX_SIZE=104857600 # 100MB max file size
export DOCX_MCP_MAX_DOCS=25 # Max 25 open documents
# Enable all features
unset DOCX_MCP_READONLY
unset DOCX_MCP_SANDBOX
unset DOCX_MCP_WHITELIST
unset DOCX_MCP_BLACKLIST
echo "🚀 Starting DOCX MCP Server in Development Mode"
./target/release/docx-mcp
```
These examples demonstrate the full power and flexibility of the DOCX MCP server for professional document workflows, from simple document creation to complex enterprise automation systems.
-503
View File
@@ -1,503 +0,0 @@
#!/usr/bin/env python3
"""
Advanced automation example using the DOCX MCP Server.
This demonstrates how to build document automation workflows.
"""
import json
import asyncio
import csv
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Any
# This would normally be your MCP client library
# For demonstration, we're showing the structure
class MCPClient:
"""Mock MCP Client for demonstration"""
async def call(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Call an MCP tool"""
# In reality, this would communicate with the MCP server
print(f"Calling {tool_name} with {arguments}")
return {"success": True, "result": {}}
# Initialize client
mcp = MCPClient()
# === Example 1: Generate Monthly Reports ===
async def generate_monthly_report(month: int, year: int, data: Dict[str, Any]):
"""Generate a comprehensive monthly report"""
# Create new document
doc = await mcp.call("create_document", {})
doc_id = doc["result"]["document_id"]
# Add title page
await mcp.call("add_heading", {
"document_id": doc_id,
"text": f"{data['company_name']}",
"level": 1
})
await mcp.call("add_heading", {
"document_id": doc_id,
"text": f"Monthly Report - {datetime(year, month, 1).strftime('%B %Y')}",
"level": 2
})
await mcp.call("add_page_break", {"document_id": doc_id})
# Executive Summary
await mcp.call("add_heading", {
"document_id": doc_id,
"text": "Executive Summary",
"level": 1
})
await mcp.call("add_paragraph", {
"document_id": doc_id,
"text": data["executive_summary"],
"style": {
"font_size": 12,
"alignment": "justify"
}
})
# Key Metrics Table
await mcp.call("add_heading", {
"document_id": doc_id,
"text": "Key Performance Indicators",
"level": 2
})
await mcp.call("add_table", {
"document_id": doc_id,
"rows": [
["Metric", "Target", "Actual", "Variance"],
["Revenue", f"${data['targets']['revenue']:,}", f"${data['actuals']['revenue']:,}",
f"{((data['actuals']['revenue'] / data['targets']['revenue'] - 1) * 100):.1f}%"],
["New Customers", str(data['targets']['customers']), str(data['actuals']['customers']),
f"{data['actuals']['customers'] - data['targets']['customers']:+d}"],
["Satisfaction Score", f"{data['targets']['satisfaction']}%", f"{data['actuals']['satisfaction']}%",
f"{data['actuals']['satisfaction'] - data['targets']['satisfaction']:+.1f}%"]
]
})
# Department Reports
for dept in data['departments']:
await mcp.call("add_heading", {
"document_id": doc_id,
"text": f"{dept['name']} Department",
"level": 2
})
await mcp.call("add_paragraph", {
"document_id": doc_id,
"text": dept['summary']
})
if dept.get('achievements'):
await mcp.call("add_list", {
"document_id": doc_id,
"items": dept['achievements'],
"ordered": False
})
# Action Items
await mcp.call("add_heading", {
"document_id": doc_id,
"text": "Action Items for Next Month",
"level": 2
})
await mcp.call("add_table", {
"document_id": doc_id,
"rows": [
["Action", "Owner", "Due Date", "Priority"],
*[[item['action'], item['owner'], item['due_date'], item['priority']]
for item in data['action_items']]
]
})
# Add footer
await mcp.call("set_footer", {
"document_id": doc_id,
"text": f"Confidential - {data['company_name']} - Page"
})
# Save as DOCX
filename = f"monthly_report_{year}_{month:02d}.docx"
await mcp.call("save_document", {
"document_id": doc_id,
"output_path": f"./reports/{filename}"
})
# Convert to PDF
await mcp.call("convert_to_pdf", {
"document_id": doc_id,
"output_path": f"./reports/{filename.replace('.docx', '.pdf')}"
})
# Generate thumbnail
await mcp.call("convert_to_images", {
"document_id": doc_id,
"output_dir": "./reports/thumbnails/",
"format": "png",
"dpi": 72
})
await mcp.call("close_document", {"document_id": doc_id})
return filename
# === Example 2: Mail Merge ===
async def mail_merge(template_path: str, csv_path: str, output_dir: str):
"""Perform mail merge with CSV data"""
# Read CSV data
with open(csv_path, 'r') as f:
reader = csv.DictReader(f)
recipients = list(reader)
generated_files = []
for recipient in recipients:
# Open template
template = await mcp.call("open_document", {"path": template_path})
doc_id = template["result"]["document_id"]
# Extract template text
text_result = await mcp.call("extract_text", {"document_id": doc_id})
text = text_result["result"]["text"]
# Replace placeholders
for field, value in recipient.items():
placeholder = f"{{{{{field}}}}}"
if placeholder in text:
await mcp.call("find_and_replace", {
"document_id": doc_id,
"find_text": placeholder,
"replace_text": value
})
# Save personalized document
output_filename = f"{recipient.get('name', 'document').replace(' ', '_')}.docx"
output_path = f"{output_dir}/{output_filename}"
await mcp.call("save_document", {
"document_id": doc_id,
"output_path": output_path
})
# Convert to PDF
pdf_path = output_path.replace('.docx', '.pdf')
await mcp.call("convert_to_pdf", {
"document_id": doc_id,
"output_path": pdf_path
})
generated_files.append({
"recipient": recipient['name'],
"docx": output_path,
"pdf": pdf_path
})
await mcp.call("close_document", {"document_id": doc_id})
# Create summary document
summary = await mcp.call("create_document", {})
summary_id = summary["result"]["document_id"]
await mcp.call("add_heading", {
"document_id": summary_id,
"text": "Mail Merge Summary",
"level": 1
})
await mcp.call("add_paragraph", {
"document_id": summary_id,
"text": f"Generated {len(generated_files)} documents on {datetime.now().strftime('%Y-%m-%d %H:%M')}"
})
# Add summary table
rows = [["Recipient", "DOCX File", "PDF File"]]
for file_info in generated_files:
rows.append([
file_info['recipient'],
file_info['docx'],
file_info['pdf']
])
await mcp.call("add_table", {
"document_id": summary_id,
"rows": rows
})
await mcp.call("save_document", {
"document_id": summary_id,
"output_path": f"{output_dir}/merge_summary.docx"
})
await mcp.call("close_document", {"document_id": summary_id})
return generated_files
# === Example 3: Document Pipeline ===
async def document_processing_pipeline(input_dir: str):
"""Process multiple documents through a pipeline"""
input_path = Path(input_dir)
docx_files = list(input_path.glob("*.docx"))
results = []
for docx_file in docx_files:
print(f"Processing {docx_file.name}...")
# Open document
doc = await mcp.call("open_document", {"path": str(docx_file)})
doc_id = doc["result"]["document_id"]
# Add watermark (header)
await mcp.call("set_header", {
"document_id": doc_id,
"text": "DRAFT - CONFIDENTIAL"
})
# Add footer with date
await mcp.call("set_footer", {
"document_id": doc_id,
"text": f"Processed on {datetime.now().strftime('%Y-%m-%d')}"
})
# Extract text for indexing
text_result = await mcp.call("extract_text", {"document_id": doc_id})
text = text_result["result"]["text"]
word_count = len(text.split())
# Save modified document
output_docx = f"./processed/{docx_file.stem}_processed.docx"
await mcp.call("save_document", {
"document_id": doc_id,
"output_path": output_docx
})
# Convert to PDF
output_pdf = output_docx.replace('.docx', '.pdf')
await mcp.call("convert_to_pdf", {
"document_id": doc_id,
"output_path": output_pdf
})
# Generate thumbnail
await mcp.call("convert_to_images", {
"document_id": doc_id,
"output_dir": "./processed/thumbnails/",
"format": "jpg",
"dpi": 96
})
results.append({
"original": docx_file.name,
"word_count": word_count,
"docx": output_docx,
"pdf": output_pdf
})
await mcp.call("close_document", {"document_id": doc_id})
# Create index document
index = await mcp.call("create_document", {})
index_id = index["result"]["document_id"]
await mcp.call("add_heading", {
"document_id": index_id,
"text": "Document Processing Report",
"level": 1
})
await mcp.call("add_paragraph", {
"document_id": index_id,
"text": f"Processed {len(results)} documents"
})
# Statistics table
rows = [["Original File", "Word Count", "Output DOCX", "Output PDF"]]
for result in results:
rows.append([
result['original'],
str(result['word_count']),
result['docx'],
result['pdf']
])
await mcp.call("add_table", {
"document_id": index_id,
"rows": rows
})
await mcp.call("save_document", {
"document_id": index_id,
"output_path": "./processed/index.docx"
})
await mcp.call("close_document", {"document_id": index_id})
return results
# === Example 4: Contract Generator ===
async def generate_contract(contract_type: str, parties: Dict[str, Any], terms: Dict[str, Any]):
"""Generate a legal contract based on type and terms"""
doc = await mcp.call("create_document", {})
doc_id = doc["result"]["document_id"]
# Title
await mcp.call("add_heading", {
"document_id": doc_id,
"text": f"{contract_type.upper()} AGREEMENT",
"level": 1
})
# Date and parties
await mcp.call("add_paragraph", {
"document_id": doc_id,
"text": f"This Agreement is entered into as of {terms['date']} between:"
})
await mcp.call("add_list", {
"document_id": doc_id,
"items": [
f"{parties['party1']['name']}, a {parties['party1']['type']} (\"Party 1\")",
f"{parties['party2']['name']}, a {parties['party2']['type']} (\"Party 2\")"
],
"ordered": False
})
# Terms sections
section_num = 1
for section_title, section_content in terms['sections'].items():
await mcp.call("add_heading", {
"document_id": doc_id,
"text": f"{section_num}. {section_title}",
"level": 2
})
if isinstance(section_content, list):
await mcp.call("add_list", {
"document_id": doc_id,
"items": section_content,
"ordered": True
})
else:
await mcp.call("add_paragraph", {
"document_id": doc_id,
"text": section_content
})
section_num += 1
# Signature block
await mcp.call("add_page_break", {"document_id": doc_id})
await mcp.call("add_heading", {
"document_id": doc_id,
"text": "SIGNATURES",
"level": 2
})
signature_table = [
["Party 1:", "", "Party 2:", ""],
["", "", "", ""],
["_" * 30, "", "_" * 30, ""],
["Name:", parties['party1']['signatory'], "Name:", parties['party2']['signatory']],
["Title:", parties['party1']['title'], "Title:", parties['party2']['title']],
["Date:", "_" * 20, "Date:", "_" * 20]
]
await mcp.call("add_table", {
"document_id": doc_id,
"rows": signature_table
})
# Save and convert
filename = f"{contract_type.lower().replace(' ', '_')}_{datetime.now().strftime('%Y%m%d')}"
await mcp.call("save_document", {
"document_id": doc_id,
"output_path": f"./contracts/{filename}.docx"
})
await mcp.call("convert_to_pdf", {
"document_id": doc_id,
"output_path": f"./contracts/{filename}.pdf"
})
await mcp.call("close_document", {"document_id": doc_id})
return filename
# === Main execution ===
async def main():
"""Run example automations"""
print("Document Automation Examples")
print("=" * 40)
# Example data for monthly report
report_data = {
"company_name": "TechCorp Industries",
"executive_summary": "This month showed strong growth across all departments...",
"targets": {"revenue": 1000000, "customers": 50, "satisfaction": 85},
"actuals": {"revenue": 1150000, "customers": 62, "satisfaction": 88.5},
"departments": [
{
"name": "Sales",
"summary": "Sales exceeded targets by 15%",
"achievements": ["Closed 3 enterprise deals", "Expanded into new market"]
},
{
"name": "Engineering",
"summary": "Delivered 2 major features on schedule",
"achievements": ["Reduced bug count by 30%", "Improved performance by 25%"]
}
],
"action_items": [
{"action": "Hire 2 senior developers", "owner": "HR", "due_date": "2024-02-15", "priority": "High"},
{"action": "Launch marketing campaign", "owner": "Marketing", "due_date": "2024-02-01", "priority": "Medium"}
]
}
# Generate monthly report
print("\n1. Generating monthly report...")
report_file = await generate_monthly_report(1, 2024, report_data)
print(f" ✓ Generated: {report_file}")
# Contract generation
print("\n2. Generating service agreement...")
contract_file = await generate_contract(
"Service Agreement",
{
"party1": {"name": "ABC Corp", "type": "corporation", "signatory": "John Smith", "title": "CEO"},
"party2": {"name": "XYZ Ltd", "type": "limited company", "signatory": "Jane Doe", "title": "Director"}
},
{
"date": "January 15, 2024",
"sections": {
"Scope of Services": "Party 2 agrees to provide consulting services...",
"Payment Terms": ["Monthly fee of $10,000", "Payment due within 30 days", "Late fee of 1.5% per month"],
"Term and Termination": "This agreement shall commence on the date first written above...",
"Confidentiality": "Both parties agree to maintain strict confidentiality..."
}
}
)
print(f" ✓ Generated: {contract_file}")
print("\n✅ All automation examples completed!")
if __name__ == "__main__":
# Create necessary directories
for dir_path in ["./reports", "./reports/thumbnails", "./contracts", "./processed", "./processed/thumbnails"]:
Path(dir_path).mkdir(parents=True, exist_ok=True)
# Run examples
asyncio.run(main())
-166
View File
@@ -1,166 +0,0 @@
# Claude Desktop Examples
These are real examples you can use with Claude Desktop once the DOCX MCP server is configured.
## Basic Document Creation
```
You: Create a new Word document with a professional letterhead for "Acme Corp" and save it as letterhead.docx
```
Claude will create a document with:
- Company name as heading
- Address and contact details
- Professional formatting
- Save to the specified file
## Invoice Generation
```
You: Generate an invoice for client "TechStart Inc" with these items:
- 10 hours consulting at $150/hour
- 1 software license at $500
- Add 10% tax
Save as invoice_2024_001.docx and convert to PDF
```
## Batch Processing
```
You: I have 5 DOCX files in the ./reports folder. Please:
1. Add page numbers to each
2. Set the header to "Confidential - Internal Use Only"
3. Convert all to PDF
4. Create a summary document listing all reports
```
## Data-Driven Documents
```
You: Create a sales report from this data:
Q1: $1.2M (15% growth)
Q2: $1.5M (25% growth)
Q3: $1.3M (8% growth)
Q4: $1.8M (38% growth)
Include:
- Executive summary
- Quarterly breakdown table
- Year-over-year comparison
- Recommendations section
Convert to PDF when done
```
## Template Operations
```
You: Open template.docx and replace these placeholders:
- {{CLIENT_NAME}} with "John Smith"
- {{DATE}} with today's date
- {{PROJECT}} with "Website Redesign"
- {{AMOUNT}} with "$5,000"
Save as contract_john_smith.docx
```
## Document Merging
```
You: Merge these documents in order:
1. cover_page.docx
2. executive_summary.docx
3. main_report.docx
4. appendix.docx
Add page numbers and a table of contents, then save as final_report.docx
```
## Text Extraction and Analysis
```
You: Extract all text from the documents in ./legal folder and:
1. Find all mentions of "liability"
2. Create a summary document with each mention and its context
3. Add a table showing which document contains which terms
```
## Report Formatting
```
You: Format this markdown content as a professional Word document:
# Project Status Report
## Overview
Project is on track...
## Milestones
- [x] Phase 1 complete
- [ ] Phase 2 in progress
## Budget
Current spend: $45,000 of $100,000
Add proper styling, convert checkboxes to a status table, and export as PDF.
```
## Document Comparison
```
You: Open contract_v1.docx and contract_v2.docx, then:
1. Extract text from both
2. Create a new document highlighting the differences
3. Add a summary table of all changes
4. Save as contract_comparison.docx
```
## Automated Documentation
```
You: Create API documentation from this OpenAPI spec file (api.yaml):
1. Generate a Word document with proper formatting
2. Include endpoint descriptions in a table
3. Add request/response examples
4. Create a PDF version for distribution
```
## Meeting Minutes Template
```
You: Create a meeting minutes template with:
- Company header
- Date, time, attendees fields
- Agenda items section
- Action items table with owner and due date columns
- Next meeting section
Save as meeting_template.docx
```
## Bulk Conversion
```
You: Convert all Word documents in my Downloads folder to:
1. PDF files in ./pdfs folder
2. PNG images (first page only) in ./thumbnails folder
3. Create an index.docx with links to all documents
```
## Complex Formatting
```
You: Create a technical specification document with:
1. Title page with document version and date
2. Table of contents (auto-generated)
3. Multiple heading levels
4. Code blocks with syntax highlighting effect
5. Diagrams placeholder sections
6. Numbered requirements list
7. Glossary table at the end
8. Footer with page numbers
```
## Mail Merge Simulation
```
You: I have a CSV with client data (clients.csv). For each client:
1. Create a personalized letter using template.docx
2. Replace all placeholders with client data
3. Save as PDF with client name in filename
4. Create a summary document listing all generated files
```
-144
View File
@@ -1,144 +0,0 @@
{
"claude_desktop": {
"description": "Claude Desktop configuration",
"file_location_macos": "~/Library/Application Support/Claude/claude_desktop_config.json",
"file_location_windows": "%APPDATA%\\Claude\\claude_desktop_config.json",
"config": {
"mcpServers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
}
}
}
},
"cursor": {
"description": "Cursor IDE configuration",
"file_location": "~/.cursor/config.json or Settings UI",
"config": {
"mcp": {
"servers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
}
}
}
}
},
"windsurf": {
"description": "Windsurf (Codeium) configuration",
"file_location": "~/.windsurf/config.json",
"config": {
"mcp": {
"servers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
}
}
}
}
},
"continue_dev": {
"description": "Continue.dev configuration",
"file_location": "~/.continue/config.json",
"config": {
"models": [
{
"title": "Claude 3.5 Sonnet",
"provider": "anthropic",
"model": "claude-3-5-sonnet-20241022",
"apiKey": "your-api-key",
"mcp_servers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": []
}
}
}
]
}
},
"vscode_mcp": {
"description": "VS Code with MCP Extension",
"file_location": ".vscode/settings.json",
"config": {
"mcp.servers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
}
}
}
},
"zed": {
"description": "Zed editor configuration",
"file_location": "~/.config/zed/settings.json",
"config": {
"assistant": {
"mcp_servers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
}
}
}
}
},
"neovim": {
"description": "Neovim with MCP support",
"file_location": "~/.config/nvim/mcp.json",
"config": {
"servers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
}
}
}
},
"multiple_servers_example": {
"description": "Example with multiple MCP servers",
"config": {
"mcpServers": {
"docx": {
"command": "/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
},
"filesystem": {
"command": "/path/to/filesystem-mcp",
"args": ["--root", "/home/user/documents"]
},
"github": {
"command": "/path/to/github-mcp",
"args": [],
"env": {
"GITHUB_TOKEN": "ghp_..."
}
}
}
}
}
}
-160
View File
@@ -1,160 +0,0 @@
#!/usr/bin/env python3
"""
Example client to test the DOCX MCP Server.
This demonstrates how to interact with the server using JSON-RPC.
"""
import json
import sys
import asyncio
import websockets
async def call_tool(websocket, tool_name, arguments):
"""Call a tool on the MCP server"""
request = {
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": arguments
},
"id": 1
}
await websocket.send(json.dumps(request))
response = await websocket.recv()
return json.loads(response)
async def main():
# Connect to the MCP server (adjust the URI as needed)
uri = "ws://localhost:3000" # Default MCP server port
async with websockets.connect(uri) as websocket:
print("Connected to DOCX MCP Server")
# Example 1: Create a new document
print("\n1. Creating new document...")
result = await call_tool(websocket, "create_document", {})
doc_id = result["result"]["document_id"]
print(f" Document created with ID: {doc_id}")
# Example 2: Add a heading
print("\n2. Adding heading...")
result = await call_tool(websocket, "add_heading", {
"document_id": doc_id,
"text": "Sample Document",
"level": 1
})
print(" Heading added")
# Example 3: Add a paragraph with styling
print("\n3. Adding styled paragraph...")
result = await call_tool(websocket, "add_paragraph", {
"document_id": doc_id,
"text": "This is a sample paragraph with custom styling.",
"style": {
"font_size": 14,
"bold": True,
"color": "#0066CC",
"alignment": "center"
}
})
print(" Styled paragraph added")
# Example 4: Add a table
print("\n4. Adding table...")
result = await call_tool(websocket, "add_table", {
"document_id": doc_id,
"rows": [
["Product", "Price", "Quantity"],
["Widget A", "$10.99", "100"],
["Widget B", "$15.99", "75"],
["Widget C", "$8.99", "150"]
]
})
print(" Table added")
# Example 5: Add a numbered list
print("\n5. Adding numbered list...")
result = await call_tool(websocket, "add_list", {
"document_id": doc_id,
"items": [
"First item in the list",
"Second item with more text",
"Third and final item"
],
"ordered": True
})
print(" Numbered list added")
# Example 6: Set header and footer
print("\n6. Setting header and footer...")
result = await call_tool(websocket, "set_header", {
"document_id": doc_id,
"text": "Sample Document Header"
})
result = await call_tool(websocket, "set_footer", {
"document_id": doc_id,
"text": "Page 1 | Confidential"
})
print(" Header and footer set")
# Example 7: Save the document
print("\n7. Saving document...")
result = await call_tool(websocket, "save_document", {
"document_id": doc_id,
"output_path": "./sample_output.docx"
})
print(" Document saved to sample_output.docx")
# Example 8: Convert to PDF
print("\n8. Converting to PDF...")
result = await call_tool(websocket, "convert_to_pdf", {
"document_id": doc_id,
"output_path": "./sample_output.pdf"
})
if result["result"]["success"]:
print(" Document converted to PDF")
else:
print(f" PDF conversion failed: {result['result'].get('error', 'Unknown error')}")
# Example 9: Convert to images
print("\n9. Converting to images...")
result = await call_tool(websocket, "convert_to_images", {
"document_id": doc_id,
"output_dir": "./images/",
"format": "png",
"dpi": 150
})
if result["result"]["success"]:
print(f" Document converted to images: {result['result']['images']}")
else:
print(f" Image conversion failed: {result['result'].get('error', 'Unknown error')}")
# Example 10: Extract text
print("\n10. Extracting text...")
result = await call_tool(websocket, "extract_text", {
"document_id": doc_id
})
text = result["result"]["text"]
print(f" Extracted text (first 100 chars): {text[:100]}...")
# Example 11: Get metadata
print("\n11. Getting metadata...")
result = await call_tool(websocket, "get_metadata", {
"document_id": doc_id
})
metadata = result["result"]["metadata"]
print(f" Document metadata: {json.dumps(metadata, indent=2)}")
# Example 12: Close the document
print("\n12. Closing document...")
result = await call_tool(websocket, "close_document", {
"document_id": doc_id
})
print(" Document closed")
print("\n✅ All tests completed successfully!")
if __name__ == "__main__":
asyncio.run(main())
-476
View File
@@ -1,476 +0,0 @@
# Justfile for docx-mcp project
# Usage: just <command>
# Install just: https://github.com/casey/just
# Default recipe
default:
@just --list
# Build the project
build:
cargo build --all-features
# Build for release
build-release:
cargo build --release --all-features
# Run all tests
test:
./scripts/run_tests.sh
# Run only unit tests
test-unit:
./scripts/run_tests.sh --unit-only
# Run only integration tests
test-integration:
./scripts/run_tests.sh --integration-only
# Run all tests including slow ones
test-all:
./scripts/run_tests.sh --all
# Run performance tests
test-performance:
./scripts/run_tests.sh --performance
# Generate coverage report
coverage:
./scripts/run_tests.sh --coverage
# Run benchmarks
bench:
cargo bench --all-features
# Check code formatting
fmt-check:
cargo fmt --all -- --check
# Format code
fmt:
cargo fmt --all
# Run Clippy lints
clippy:
cargo clippy --all-targets --all-features -- -D warnings
# Fix Clippy issues automatically where possible
clippy-fix:
cargo clippy --all-targets --all-features --fix
# Run security audit
audit:
cargo audit
# Check dependencies for issues
deny:
cargo deny check
# Clean build artifacts
clean:
cargo clean
# Update dependencies
update:
cargo update
# Install development tools
install-dev-tools:
cargo install cargo-audit cargo-deny cargo-llvm-cov
# Run the application in development mode
dev:
RUST_LOG=debug cargo run --all-features
# Run the application in release mode
run:
cargo run --release --all-features
# Generate documentation
docs:
cargo doc --all-features --no-deps --open
# Check documentation
docs-check:
cargo doc --all-features --no-deps
# Package the project for distribution
package:
cargo package
# Publish to crates.io (dry run)
publish-dry:
cargo publish --dry-run
# Publish to crates.io
publish:
cargo publish
# Docker build
docker-build:
docker build -t docx-mcp:latest .
# Docker run
docker-run:
docker run -p 8080:8080 docx-mcp:latest
# Run pre-commit checks (formatting, linting, tests)
pre-commit: fmt-check clippy test-unit
# Full CI pipeline simulation
ci: pre-commit test audit
# Quick development cycle (format, build, test)
dev-cycle: fmt build test-unit
# Setup development environment
setup:
rustup component add rustfmt clippy llvm-tools-preview
just install-dev-tools
# Generate sample documents for testing
generate-samples:
cargo run --bin generate-test-docs --features=test-utils
# Run stress tests
stress-test:
STRESS_TEST=1 cargo test --release --test performance_tests -- --ignored --test-threads=1
# Profile the application
profile:
cargo build --release --all-features
perf record -g target/release/docx-mcp
perf report
# Memory usage analysis
memory-check:
cargo build --all-features
valgrind --tool=memcheck --leak-check=full target/debug/docx-mcp
# Run with different Rust versions (requires rustup)
test-msrv:
rustup install 1.70.0
rustup run 1.70.0 cargo test
# Check for outdated dependencies
outdated:
cargo install cargo-outdated
cargo outdated
# Security scan
security-scan: audit deny
# Performance profiling with flamegraph
flamegraph:
cargo install flamegraph
cargo flamegraph --bin docx-mcp
# Generate changelog (requires git-cliff)
changelog:
git cliff --output CHANGELOG.md
# Release commands using the release script
# Create a patch release (0.1.0 -> 0.1.1)
release-patch:
./scripts/release.sh patch
# Create a minor release (0.1.0 -> 0.2.0)
release-minor:
./scripts/release.sh minor
# Create a major release (0.1.0 -> 1.0.0)
release-major:
./scripts/release.sh major
# Create a specific version release
release-version version:
./scripts/release.sh version {{version}}
# Dry run of patch release (see what would happen)
release-patch-dry:
./scripts/release.sh patch --dry-run
# Dry run of minor release
release-minor-dry:
./scripts/release.sh minor --dry-run
# Dry run of major release
release-major-dry:
./scripts/release.sh major --dry-run
# Dry run of specific version release
release-version-dry version:
./scripts/release.sh version {{version}} --dry-run
# Run all pre-release checks
release-check:
./scripts/release.sh check
# Generate changelog since last tag
release-changelog:
./scripts/release.sh changelog
# Create git tag for current version
release-tag:
./scripts/release.sh tag
# Prepare a release (legacy command - use release-* commands above)
prepare-release version:
@echo "⚠️ This command is deprecated. Use 'just release-version {{version}}' instead."
@echo "The new release commands provide better automation and safety checks."
@echo ""
@echo "Available release commands:"
@echo " just release-patch - Bump patch version"
@echo " just release-minor - Bump minor version"
@echo " just release-major - Bump major version"
@echo " just release-version X.Y.Z - Set specific version"
# Show project statistics
stats:
@echo "=== Project Statistics ==="
@echo "Lines of code:"
@find src -name "*.rs" -type f -exec wc -l {} + | tail -n 1
@echo ""
@echo "Test coverage:"
@just coverage --quiet | grep "Overall coverage" || echo "Run 'just coverage' first"
@echo ""
@echo "Dependencies:"
@cargo tree --depth 1 | wc -l
@echo ""
@echo "Binary size (release):"
@if [ -f "target/release/docx-mcp" ]; then ls -lh target/release/docx-mcp | awk '{print $5}'; else echo "Run 'just build-release' first"; fi
# Watch for changes and run tests
watch:
cargo install cargo-watch
cargo watch -x "test --lib"
# Watch for changes and run specific test
watch-test test_name:
cargo watch -x "test {{test_name}}"
# Initialize git hooks
init-hooks:
#!/usr/bin/env bash
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/bash
just pre-commit
EOF
chmod +x .git/hooks/pre-commit
echo "Pre-commit hook installed"
# Remove git hooks
remove-hooks:
rm -f .git/hooks/pre-commit
echo "Pre-commit hook removed"
# Docker commands
# Build multi-platform Docker image
docker-build-multiarch:
docker buildx create --use --name multiarch || true
docker buildx build --platform linux/amd64,linux/arm64 -t docx-mcp:latest .
# Build and tag Docker image for release
docker-build-release version:
docker buildx build --platform linux/amd64,linux/arm64 \
-t docx-mcp:{{version}} \
-t docx-mcp:latest \
-t ghcr.io/hongkongkiwi/docx-mcp:{{version}} \
-t ghcr.io/hongkongkiwi/docx-mcp:latest \
.
# Push Docker images to registry
docker-push version:
docker push docx-mcp:{{version}}
docker push docx-mcp:latest
docker push ghcr.io/hongkongkiwi/docx-mcp:{{version}}
docker push ghcr.io/hongkongkiwi/docx-mcp:latest
# Run Docker container with volume mount for testing
docker-test:
docker run --rm -it -v $(pwd)/test-docs:/test-docs docx-mcp:latest
# Development environment commands
# Full development setup from scratch
dev-setup:
# Install Rust if not present
@if ! command -v rustup >/dev/null 2>&1; then \
echo "Installing Rust..."; \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y; \
source ~/.cargo/env; \
fi
# Setup toolchain and tools
just setup
# Initialize git hooks
just init-hooks
# Build project
just build
echo "✅ Development environment ready!"
# Check system dependencies
check-deps:
@echo "=== System Dependencies Check ==="
@echo "Checking required tools..."
@command -v rustc >/dev/null && echo "✅ Rust compiler found" || echo "❌ Rust compiler not found"
@command -v cargo >/dev/null && echo "✅ Cargo found" || echo "❌ Cargo not found"
@command -v git >/dev/null && echo "✅ Git found" || echo "❌ Git not found"
@command -v docker >/dev/null && echo "✅ Docker found" || echo "❌ Docker not found"
@command -v just >/dev/null && echo "✅ Just found" || echo "❌ Just not found"
@echo ""
@echo "Optional tools:"
@command -v libreoffice >/dev/null && echo "✅ LibreOffice found" || echo "⚠️ LibreOffice not found (optional)"
@command -v pdftoppm >/dev/null && echo "✅ pdftoppm found" || echo "⚠️ pdftoppm not found (optional)"
@command -v convert >/dev/null && echo "✅ ImageMagick convert found" || echo "⚠️ ImageMagick not found (optional)"
# Cross-compilation commands
# Build for all supported targets
build-all-targets:
# Install targets if not present
rustup target add x86_64-unknown-linux-gnu
rustup target add x86_64-unknown-linux-musl
rustup target add aarch64-unknown-linux-gnu
rustup target add x86_64-apple-darwin
rustup target add aarch64-apple-darwin
rustup target add x86_64-pc-windows-msvc
# Build for each target
cargo build --release --target x86_64-unknown-linux-gnu --all-features
cargo build --release --target x86_64-unknown-linux-musl --all-features
cargo build --release --target x86_64-apple-darwin --all-features
@echo "✅ Built for all available targets"
# Build using cross for Linux targets
build-cross-linux:
cargo install cross --git https://github.com/cross-rs/cross
cross build --release --target x86_64-unknown-linux-gnu --all-features
cross build --release --target x86_64-unknown-linux-musl --all-features
cross build --release --target aarch64-unknown-linux-gnu --all-features
cross build --release --target aarch64-unknown-linux-musl --all-features
# Maintenance commands
# Update all dependencies to latest versions
update-deps:
cargo update
cargo outdated --depth 1
# Check for security vulnerabilities and update
security-update:
cargo audit fix
cargo update
# Clean everything (including registry cache)
clean-all:
cargo clean
rm -rf ~/.cargo/registry/cache
rm -rf ~/.cargo/git/db
docker system prune -f
# Backup project (excluding target and build artifacts)
backup:
#!/usr/bin/env bash
BACKUP_NAME="docx-mcp-backup-$(date +%Y%m%d-%H%M%S)"
tar czf "${BACKUP_NAME}.tar.gz" \
--exclude='target' \
--exclude='.git' \
--exclude='*.log' \
--exclude='*.tmp' \
.
echo "✅ Backup created: ${BACKUP_NAME}.tar.gz"
# Development workflows
# Quick development loop (format, build, test unit, lint)
dev-loop:
just fmt
just build
just test-unit
just clippy
# Full quality check (everything CI runs)
quality-check:
just fmt-check
just clippy
just test
just docs-check
just audit
just deny
# Continuous development with file watching
dev-watch:
cargo install cargo-watch
cargo watch -w src -w tests -x "build" -x "test --lib"
# Performance analysis
perf-analysis:
# Build optimized release
cargo build --release --all-features
# Run criterion benchmarks
cargo bench --all-features
# Generate flamegraph if available
@if command -v flamegraph >/dev/null 2>&1; then \
echo "Generating flamegraph..."; \
cargo flamegraph --bin docx-mcp -- --help; \
fi
# MCP-specific commands
# Test MCP server functionality
test-mcp:
@echo "Testing MCP server..."
# Build the server
cargo build --release --all-features
# Run basic functionality test
python3 example/test_client.py || echo "❌ MCP test failed"
# Generate MCP documentation
mcp-docs:
@echo "Generating MCP server documentation..."
cargo run --bin docx-mcp -- --help > docs/CLI_REFERENCE.md
@echo "✅ CLI reference updated"
# Example commands
# Run all examples
run-examples:
@echo "Running all examples..."
@if [ -f example/test_client.py ]; then python3 example/test_client.py; fi
@if [ -f example/automation_example.py ]; then python3 example/automation_example.py; fi
# Generate test documents
gen-test-docs:
@echo "Generating test documents..."
mkdir -p test-docs
# You could add commands here to generate various test DOCX files
# Utility commands
# Show detailed project info
info:
@echo "=== Project Information ==="
@echo "Name: docx-mcp"
@echo "Version: $(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')"
@echo "Rust version: $(rustc --version)"
@echo "Cargo version: $(cargo --version)"
@echo ""
just stats
# List all available commands with descriptions
help:
@echo "=== Available Commands ==="
@just --list
@echo ""
@echo "=== Release Commands ==="
@echo " release-patch - Create patch release (0.1.0 -> 0.1.1)"
@echo " release-minor - Create minor release (0.1.0 -> 0.2.0)"
@echo " release-major - Create major release (0.1.0 -> 1.0.0)"
@echo " release-version X - Create specific version release"
@echo " release-*-dry - Dry run versions of above commands"
@echo ""
@echo "=== Development Workflows ==="
@echo " dev-loop - Quick development cycle"
@echo " quality-check - Full quality assessment"
@echo " dev-setup - Complete development environment setup"
+26
View File
@@ -0,0 +1,26 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "py-docx-mcp"
version = "0.1.0"
description = "Python MCP server for DOCX document manipulation"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.24.0",
"python-docx>=1.1.0",
"Pillow>=10.0.0",
"markdown>=3.5",
"html5lib>=1.1",
"regex>=2024.0.0",
"aiofiles>=24.0.0",
]
[project.scripts]
py-docx-mcp = "server:main"
[tool.setuptools.packages.find]
where = ["src"]
include = ["*"]
-355
View File
@@ -1,355 +0,0 @@
#!/bin/bash
# Release script for docx-mcp
# This script helps with version management and release preparation
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Helper functions
info() {
echo -e "${BLUE}$1${NC}"
}
success() {
echo -e "${GREEN}$1${NC}"
}
warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
error() {
echo -e "${RED}$1${NC}"
}
# Check if we're in a git repository
check_git_repo() {
if ! git rev-parse --git-dir > /dev/null 2>&1; then
error "Not in a git repository"
exit 1
fi
}
# Check if working directory is clean
check_clean_working_dir() {
if ! git diff-index --quiet HEAD --; then
error "Working directory is not clean. Please commit or stash your changes."
exit 1
fi
}
# Get current version from Cargo.toml
get_current_version() {
grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/'
}
# Update version in Cargo.toml
update_version() {
local new_version=$1
info "Updating version to $new_version"
# Update Cargo.toml
sed -i.bak "s/^version = \".*\"/version = \"$new_version\"/" Cargo.toml
rm Cargo.toml.bak
# Update Cargo.lock
cargo update -p docx-mcp
success "Version updated to $new_version"
}
# Generate changelog since last tag
generate_changelog() {
local last_tag=$(git tag --sort=-version:refname | head -1)
local new_version=$1
info "Generating changelog since $last_tag"
if [ -n "$last_tag" ]; then
git log --pretty=format:"- %s (%h)" --no-merges ${last_tag}..HEAD > CHANGELOG.tmp
else
git log --pretty=format:"- %s (%h)" --no-merges > CHANGELOG.tmp
fi
echo "## Release $new_version ($(date +%Y-%m-%d))"
echo ""
cat CHANGELOG.tmp
echo ""
rm CHANGELOG.tmp
}
# Run pre-release checks
run_checks() {
info "Running pre-release checks..."
# Format check
info "Checking code formatting..."
cargo fmt --all -- --check
success "Code formatting is correct"
# Clippy check
info "Running Clippy..."
cargo clippy --all-targets --all-features -- -D warnings
success "Clippy checks passed"
# Tests
info "Running tests..."
cargo test --all-features
success "All tests passed"
# Build check
info "Testing release build..."
cargo build --release --all-features
success "Release build successful"
# Package check
info "Testing package..."
cargo package --dry-run
success "Package validation passed"
}
# Create and push git tag
create_tag() {
local version=$1
local tag="v$version"
info "Creating git tag $tag"
# Create annotated tag
git tag -a "$tag" -m "Release $tag"
success "Created tag $tag"
# Ask if user wants to push
echo -n "Push tag to origin? [y/N]: "
read -r response
if [[ "$response" =~ ^[Yy]$ ]]; then
git push origin "$tag"
success "Tag pushed to origin"
else
warning "Tag not pushed. Remember to push it manually: git push origin $tag"
fi
}
# Show usage information
usage() {
cat << EOF
Usage: $0 [COMMAND] [OPTIONS]
Commands:
patch Bump patch version (0.1.0 -> 0.1.1)
minor Bump minor version (0.1.0 -> 0.2.0)
major Bump major version (0.1.0 -> 1.0.0)
version X.Y.Z Set specific version
check Run pre-release checks only
changelog Generate changelog since last tag
tag Create git tag for current version
Options:
--dry-run Show what would be done without making changes
--no-checks Skip pre-release checks (not recommended)
--no-tag Don't create git tag
--help Show this help message
Examples:
$0 patch # Bump to next patch version
$0 version 1.0.0 # Set version to 1.0.0
$0 check # Run all pre-release checks
$0 patch --dry-run # Show what patch release would do
EOF
}
# Parse version bump type
bump_version() {
local current_version=$1
local bump_type=$2
# Split version into components
IFS='.' read -ra VERSION_PARTS <<< "$current_version"
local major=${VERSION_PARTS[0]}
local minor=${VERSION_PARTS[1]}
local patch=${VERSION_PARTS[2]}
case $bump_type in
"patch")
patch=$((patch + 1))
;;
"minor")
minor=$((minor + 1))
patch=0
;;
"major")
major=$((major + 1))
minor=0
patch=0
;;
*)
error "Invalid bump type: $bump_type"
exit 1
;;
esac
echo "${major}.${minor}.${patch}"
}
# Validate version format
validate_version() {
local version=$1
if ! [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
error "Invalid version format: $version"
error "Expected format: X.Y.Z or X.Y.Z-suffix"
exit 1
fi
}
# Main script logic
main() {
local command=$1
local dry_run=false
local no_checks=false
local no_tag=false
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run)
dry_run=true
shift
;;
--no-checks)
no_checks=true
shift
;;
--no-tag)
no_tag=true
shift
;;
--help)
usage
exit 0
;;
*)
if [ -z "$command" ]; then
command=$1
elif [ -z "$version_arg" ] && [ "$command" = "version" ]; then
version_arg=$1
fi
shift
;;
esac
done
# Check if command provided
if [ -z "$command" ]; then
usage
exit 1
fi
# Basic checks
check_git_repo
if [ "$dry_run" = false ]; then
check_clean_working_dir
fi
current_version=$(get_current_version)
info "Current version: $current_version"
case $command in
"patch"|"minor"|"major")
new_version=$(bump_version "$current_version" "$command")
;;
"version")
if [ -z "$version_arg" ]; then
error "Version argument required for 'version' command"
exit 1
fi
new_version=$version_arg
validate_version "$new_version"
;;
"check")
run_checks
success "All pre-release checks passed!"
exit 0
;;
"changelog")
generate_changelog "$current_version"
exit 0
;;
"tag")
if [ "$dry_run" = true ]; then
info "Would create tag v$current_version"
else
create_tag "$current_version"
fi
exit 0
;;
*)
error "Unknown command: $command"
usage
exit 1
;;
esac
info "New version will be: $new_version"
if [ "$dry_run" = true ]; then
warning "DRY RUN MODE - No changes will be made"
info "Would update version from $current_version to $new_version"
if [ "$no_checks" = false ]; then
info "Would run pre-release checks"
fi
if [ "$no_tag" = false ]; then
info "Would create git tag v$new_version"
fi
exit 0
fi
# Confirm with user
echo -n "Proceed with release $new_version? [y/N]: "
read -r response
if [[ ! "$response" =~ ^[Yy]$ ]]; then
warning "Release cancelled"
exit 0
fi
# Run pre-release checks
if [ "$no_checks" = false ]; then
run_checks
fi
# Update version
update_version "$new_version"
# Commit version bump
git add Cargo.toml Cargo.lock
git commit -m "Release $new_version"
success "Version bump committed"
# Create tag
if [ "$no_tag" = false ]; then
create_tag "$new_version"
fi
# Generate changelog for reference
info "Changelog for release:"
generate_changelog "$new_version"
success "Release $new_version completed!"
info "Next steps:"
info "1. Push commits: git push origin main"
if [ "$no_tag" = false ]; then
info "2. Push tag: git push origin v$new_version (if not done already)"
fi
info "3. GitHub Actions will automatically create the release"
}
# Run main function with all arguments
main "$@"
-312
View File
@@ -1,312 +0,0 @@
#!/bin/bash
# Comprehensive test runner script for docx-mcp
# Usage: ./scripts/run_tests.sh [OPTIONS]
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Default options
RUN_UNIT_TESTS=true
RUN_INTEGRATION_TESTS=true
RUN_PERFORMANCE_TESTS=false
RUN_BENCHMARKS=false
RUN_SECURITY_AUDIT=true
RUN_COVERAGE=false
VERBOSE=false
QUIET=false
CLEAN_FIRST=false
# Function to print colored output
print_status() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Function to show help
show_help() {
cat << EOF
Usage: $0 [OPTIONS]
Test runner script for docx-mcp project
OPTIONS:
-h, --help Show this help message
-u, --unit-only Run only unit tests
-i, --integration-only Run only integration tests
-p, --performance Include performance tests (slow)
-b, --benchmarks Run benchmarks (slow)
-s, --skip-security Skip security audit
-c, --coverage Generate coverage report
-v, --verbose Verbose output
-q, --quiet Quiet output (errors only)
--clean Clean build artifacts first
--all Run all tests including slow ones
Examples:
$0 # Run standard test suite
$0 -u # Run only unit tests
$0 --all # Run all tests including performance
$0 -c --verbose # Generate coverage with verbose output
EOF
}
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
show_help
exit 0
;;
-u|--unit-only)
RUN_UNIT_TESTS=true
RUN_INTEGRATION_TESTS=false
shift
;;
-i|--integration-only)
RUN_UNIT_TESTS=false
RUN_INTEGRATION_TESTS=true
shift
;;
-p|--performance)
RUN_PERFORMANCE_TESTS=true
shift
;;
-b|--benchmarks)
RUN_BENCHMARKS=true
shift
;;
-s|--skip-security)
RUN_SECURITY_AUDIT=false
shift
;;
-c|--coverage)
RUN_COVERAGE=true
shift
;;
-v|--verbose)
VERBOSE=true
shift
;;
-q|--quiet)
QUIET=true
shift
;;
--clean)
CLEAN_FIRST=true
shift
;;
--all)
RUN_UNIT_TESTS=true
RUN_INTEGRATION_TESTS=true
RUN_PERFORMANCE_TESTS=true
RUN_BENCHMARKS=true
shift
;;
*)
print_error "Unknown option: $1"
show_help
exit 1
;;
esac
done
# Set up output redirection based on quiet flag
if [ "$QUIET" = true ]; then
CARGO_OUTPUT="--quiet"
else
CARGO_OUTPUT=""
fi
if [ "$VERBOSE" = true ]; then
CARGO_OUTPUT="$CARGO_OUTPUT --verbose"
fi
print_status "Starting docx-mcp test suite"
# Check if we're in the right directory
if [ ! -f "Cargo.toml" ]; then
print_error "Cargo.toml not found. Please run this script from the project root."
exit 1
fi
# Check if Rust is installed
if ! command -v cargo &> /dev/null; then
print_error "Cargo not found. Please install Rust first."
exit 1
fi
# Clean build artifacts if requested
if [ "$CLEAN_FIRST" = true ]; then
print_status "Cleaning build artifacts..."
cargo clean $CARGO_OUTPUT
fi
# Check formatting
print_status "Checking code formatting..."
if ! cargo fmt --all -- --check; then
print_error "Code formatting issues found. Run 'cargo fmt' to fix."
exit 1
fi
print_success "Code formatting OK"
# Run Clippy lints
print_status "Running Clippy lints..."
if ! cargo clippy --all-targets --all-features $CARGO_OUTPUT -- -D warnings; then
print_error "Clippy lints failed"
exit 1
fi
print_success "Clippy lints passed"
# Build the project
print_status "Building project..."
if ! cargo build --all-features $CARGO_OUTPUT; then
print_error "Build failed"
exit 1
fi
print_success "Build completed"
# Initialize test results tracking
TESTS_PASSED=0
TESTS_FAILED=0
FAILED_TESTS=()
# Function to run a test and track results
run_test() {
local test_name="$1"
local test_command="$2"
print_status "Running $test_name..."
if eval $test_command; then
print_success "$test_name passed"
((TESTS_PASSED++))
else
print_error "$test_name failed"
((TESTS_FAILED++))
FAILED_TESTS+=("$test_name")
fi
}
# Run unit tests
if [ "$RUN_UNIT_TESTS" = true ]; then
run_test "unit tests" "cargo test --lib $CARGO_OUTPUT"
run_test "doc tests" "cargo test --doc $CARGO_OUTPUT"
fi
# Run integration tests
if [ "$RUN_INTEGRATION_TESTS" = true ]; then
run_test "DOCX handler tests" "cargo test --test docx_handler_tests $CARGO_OUTPUT"
run_test "MCP integration tests" "cargo test --test mcp_integration_tests $CARGO_OUTPUT"
run_test "security tests" "cargo test --test security_tests $CARGO_OUTPUT"
run_test "converter tests" "cargo test --test converter_tests $CARGO_OUTPUT"
run_test "end-to-end workflow tests" "cargo test --test e2e_workflow_tests $CARGO_OUTPUT"
fi
# Run performance tests (if requested)
if [ "$RUN_PERFORMANCE_TESTS" = true ]; then
print_warning "Running performance tests (this may take a while)..."
run_test "performance tests" "cargo test --test performance_tests $CARGO_OUTPUT --release"
fi
# Run benchmarks (if requested)
if [ "$RUN_BENCHMARKS" = true ]; then
print_warning "Running benchmarks (this may take a while)..."
run_test "benchmarks" "cargo bench --all-features $CARGO_OUTPUT"
fi
# Run security audit
if [ "$RUN_SECURITY_AUDIT" = true ]; then
print_status "Running security audit..."
# Install cargo-audit if not present
if ! command -v cargo-audit &> /dev/null; then
print_status "Installing cargo-audit..."
cargo install cargo-audit
fi
run_test "security audit" "cargo audit"
# Check for denied dependencies if cargo-deny is available
if command -v cargo-deny &> /dev/null; then
run_test "dependency check" "cargo deny check"
else
print_warning "cargo-deny not found, skipping dependency checks"
fi
fi
# Generate coverage report (if requested)
if [ "$RUN_COVERAGE" = true ]; then
print_status "Generating coverage report..."
# Check if cargo-llvm-cov is installed
if ! command -v cargo-llvm-cov &> /dev/null; then
print_status "Installing cargo-llvm-cov..."
cargo install cargo-llvm-cov
fi
if cargo llvm-cov --all-features --workspace --html; then
print_success "Coverage report generated in target/llvm-cov/html/"
# Also generate lcov format for CI
if cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info; then
print_success "LCOV format generated as lcov.info"
fi
else
print_error "Coverage generation failed"
((TESTS_FAILED++))
FAILED_TESTS+=("coverage generation")
fi
fi
# Test different feature configurations
print_status "Testing different feature configurations..."
run_test "minimal features" "cargo test --no-default-features $CARGO_OUTPUT"
run_test "all features" "cargo test --all-features $CARGO_OUTPUT"
# Check that package builds for release
print_status "Verifying release build..."
run_test "release build" "cargo build --release --all-features $CARGO_OUTPUT"
# Verify package can be published (dry run)
print_status "Verifying package..."
run_test "package verification" "cargo package --dry-run $CARGO_OUTPUT"
# Print final results
echo ""
print_status "============ Test Results Summary ============"
if [ $TESTS_FAILED -eq 0 ]; then
print_success "All tests passed! ($TESTS_PASSED/$((TESTS_PASSED + TESTS_FAILED)))"
echo ""
print_status "Ready for deployment! 🚀"
exit 0
else
print_error "Some tests failed! ($TESTS_PASSED passed, $TESTS_FAILED failed)"
echo ""
print_error "Failed tests:"
for test in "${FAILED_TESTS[@]}"; do
echo -e " ${RED}${NC} $test"
done
echo ""
print_error "Please fix the failing tests before proceeding."
exit 1
fi
+1
View File
@@ -0,0 +1 @@
# py-docx-mcp: Python MCP server for DOCX document manipulation
-716
View File
@@ -1,716 +0,0 @@
use anyhow::{Context, Result};
use docx_rs::*;
use std::collections::HashMap;
use std::fs::File;
use std::io::Read;
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use base64;
/// Advanced DOCX manipulation features
pub struct AdvancedDocxHandler;
impl AdvancedDocxHandler {
pub fn new() -> Self {
Self
}
/// Create a document with professional template
pub fn create_from_template(&self, template_type: DocumentTemplate) -> Result<Docx> {
let mut docx = Docx::new();
match template_type {
DocumentTemplate::BusinessLetter => {
docx = self.apply_business_letter_template(docx)?;
}
DocumentTemplate::Resume => {
docx = self.apply_resume_template(docx)?;
}
DocumentTemplate::Report => {
docx = self.apply_report_template(docx)?;
}
DocumentTemplate::Invoice => {
docx = self.apply_invoice_template(docx)?;
}
DocumentTemplate::Contract => {
docx = self.apply_contract_template(docx)?;
}
DocumentTemplate::Memo => {
docx = self.apply_memo_template(docx)?;
}
DocumentTemplate::Newsletter => {
docx = self.apply_newsletter_template(docx)?;
}
}
Ok(docx)
}
/// Add a table of contents
pub fn add_table_of_contents(&self, docx: Docx) -> Result<Docx> {
// Basic TOC insertion (heading text paragraph + placeholder)
let mut docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("Table of Contents").bold().size(28))
.style("TOCHeading")
);
// Add instruction text
let instruction = Paragraph::new()
.add_run(
Run::new()
.add_text("Right-click and select 'Update Field' to refresh the table of contents")
.italic()
.size(20)
.color("808080")
);
docx = docx.add_paragraph(instruction);
docx = docx.add_paragraph(Paragraph::new().add_run(Run::new().add_break(BreakType::Page)));
Ok(docx)
}
/// Add an image to the document
pub fn add_image(
&self,
docx: Docx,
_image_data: &[u8],
width_px: u32,
height_px: u32,
alt_text: Option<&str>
) -> Result<Docx> {
// Try to attach a Drawing to the Run via RunChild using the public add_pic shortcut
let pic = Pic::new_with_dimensions(_image_data.to_vec(), width_px, height_px);
let paragraph = Paragraph::new().add_run({
let run = Run::new();
run.add_image(pic)
});
Ok(docx.add_paragraph(paragraph))
}
/// Add a chart to the document
pub fn add_chart(&self, docx: Docx, chart_type: ChartType, data: ChartData) -> Result<Docx> {
// Charts in DOCX are complex and usually require embedding Excel data
// For now, we'll create a table representation
let mut table = Table::new(vec![]);
// Add headers
let mut header_cells = vec![TableCell::new().add_paragraph(
Paragraph::new().add_run(Run::new().add_text("Category").bold())
)];
for series in &data.series {
header_cells.push(
TableCell::new().add_paragraph(
Paragraph::new().add_run(Run::new().add_text(&series.name).bold())
)
);
}
table = table.add_row(TableRow::new(header_cells));
// Add data rows
for (i, category) in data.categories.iter().enumerate() {
let mut row_cells = vec![TableCell::new().add_paragraph(
Paragraph::new().add_run(Run::new().add_text(category))
)];
for series in &data.series {
if let Some(value) = series.values.get(i) {
row_cells.push(
TableCell::new().add_paragraph(
Paragraph::new().add_run(Run::new().add_text(&value.to_string()))
)
);
}
}
table = table.add_row(TableRow::new(row_cells));
}
// Add title for the chart
let title = Paragraph::new()
.add_run(Run::new().add_text(&format!("{:?}: {}", chart_type, data.title)).bold())
.align(AlignmentType::Center);
Ok(docx.add_paragraph(title).add_table(table))
}
/// Add a hyperlink
pub fn add_hyperlink(&self, docx: Docx, text: &str, url: &str) -> Result<Docx> {
let hyperlink = Hyperlink::new(url, HyperlinkType::External)
.add_run(Run::new().add_text(text).color("0000FF").underline("single"));
let paragraph = Paragraph::new().add_hyperlink(hyperlink);
Ok(docx.add_paragraph(paragraph))
}
/// Add a bookmark
pub fn add_bookmark(&self, docx: Docx, bookmark_name: &str, text: &str) -> Result<Docx> {
// Bookmark IDs in 0.4 are usize; fallback to plain paragraph with text
let paragraph = Paragraph::new().add_run(Run::new().add_text(text));
Ok(docx.add_paragraph(paragraph))
}
/// Add a cross-reference
pub fn add_cross_reference(&self, docx: Docx, bookmark_name: &str, display_text: &str) -> Result<Docx> {
// Cross-references in DOCX use field codes
// Complex field support is limited in current docx-rs; fallback to plain hyperlink
// Fallback: hyperlink not wired; emit text with target in brackets
let paragraph = Paragraph::new().add_run(Run::new().add_text(format!("{} ({})", display_text, bookmark_name)));
Ok(docx.add_paragraph(paragraph))
}
/// Add document properties and metadata
pub fn set_document_properties(&self, docx: Docx, _properties: DocumentProperties) -> Result<Docx> {
// Metadata setters not exposed; return unchanged
Ok(docx)
}
/// Add a custom styled section
pub fn add_section(&self, docx: Docx, section_config: SectionConfig) -> Result<Docx> {
// Basic section properties (defaults). Page size/columns APIs differ; using defaults.
Ok(docx)
}
/// Add a watermark
pub fn add_watermark(&self, docx: Docx, text: &str, style: WatermarkStyle) -> Result<Docx> {
let watermark = match style {
WatermarkStyle::Diagonal => {
Run::new()
.add_text(text)
.size(144) // Large size
.color("C0C0C0") // Light gray
.bold()
}
WatermarkStyle::Horizontal => {
Run::new()
.add_text(text)
.size(100)
.color("E0E0E0")
}
};
// Watermarks are typically added to headers
let header = Header::new().add_paragraph(
Paragraph::new()
.add_run(watermark)
.align(AlignmentType::Center)
);
Ok(docx.header(header))
}
/// Add footnote
pub fn add_footnote(&self, docx: Docx, reference_text: &str, footnote_text: &str) -> Result<Docx> {
let footnote_id = Uuid::new_v4().to_string();
// docx-rs footnote APIs are in flux; append note text inline as fallback
let paragraph = Paragraph::new()
.add_run(Run::new().add_text(reference_text))
.add_run(Run::new().add_text(format!(" [{}]", footnote_text)));
Ok(docx.add_paragraph(paragraph))
}
/// Add endnote
pub fn add_endnote(&self, docx: Docx, reference_text: &str, endnote_text: &str) -> Result<Docx> {
let endnote_id = Uuid::new_v4().to_string();
// Fallback inline rendering for endnotes
let paragraph = Paragraph::new()
.add_run(Run::new().add_text(reference_text))
.add_run(Run::new().add_text(format!(" [{}]", endnote_text)));
Ok(docx.add_paragraph(paragraph))
}
/// Add custom styles
pub fn add_custom_style(&self, docx: Docx, _style: CustomStyle) -> Result<Docx> {
// Style builder APIs differ; skip custom styles for now
Ok(docx)
}
/// Mail merge functionality
pub fn prepare_mail_merge_template(&self, docx: Docx, fields: Vec<String>) -> Result<Docx> {
let mut docx = docx;
for field in fields {
let paragraph = Paragraph::new()
.add_run(Run::new().add_text(format!("«{}»", field)));
docx = docx.add_paragraph(paragraph);
}
Ok(docx)
}
/// Add comments (annotations)
pub fn add_comment(&self, docx: Docx, text: &str, comment: &str, author: &str) -> Result<Docx> {
let comment_id = Uuid::new_v4().to_string();
let date = Utc::now();
// Fallback: inline annotation style rendering (no true comment element)
let paragraph = Paragraph::new()
.add_run(Run::new().add_text(text))
.add_run(Run::new().add_text(format!(" [Comment by {}: {}]", author, comment)));
Ok(docx.add_paragraph(paragraph))
}
// Template helper methods
fn apply_business_letter_template(&self, mut docx: Docx) -> Result<Docx> {
// Add sender info placeholder
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Your Name]"))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_text("[Your Address]"))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_text("[City, State ZIP]"))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_text("[Your Email]"))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_text("[Your Phone]"))
);
docx = docx.add_paragraph(Paragraph::new());
// Date
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Date]"))
);
docx = docx.add_paragraph(Paragraph::new());
// Recipient info
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Recipient Name]"))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_text("[Title]"))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_text("[Company]"))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_text("[Address]"))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_text("[City, State ZIP]"))
);
docx = docx.add_paragraph(Paragraph::new());
// Salutation
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("Dear [Recipient Name]:"))
);
docx = docx.add_paragraph(Paragraph::new());
// Body placeholder
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Letter body paragraph 1]"))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Letter body paragraph 2]"))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Letter body paragraph 3]"))
);
docx = docx.add_paragraph(Paragraph::new());
// Closing
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("Sincerely,"))
);
docx = docx.add_paragraph(Paragraph::new());
docx = docx.add_paragraph(Paragraph::new());
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Your Name]"))
);
Ok(docx)
}
fn apply_resume_template(&self, mut docx: Docx) -> Result<Docx> {
// Name header
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[YOUR NAME]").size(32).bold())
.align(AlignmentType::Center)
);
// Contact info
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Email] | [Phone] | [LinkedIn] | [Location]").size(22))
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(Paragraph::new().add_run(Run::new().add_text("").size(12)));
// Professional Summary
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("PROFESSIONAL SUMMARY").size(24).bold())
.style("Heading2")
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[2-3 lines summarizing your experience and key skills]"))
);
// Experience
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("EXPERIENCE").size(24).bold())
.style("Heading2")
);
// Education
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("EDUCATION").size(24).bold())
.style("Heading2")
);
// Skills
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("SKILLS").size(24).bold())
.style("Heading2")
);
Ok(docx)
}
fn apply_report_template(&self, mut docx: Docx) -> Result<Docx> {
// Title page
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text(""))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_break(BreakType::TextWrapping))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[REPORT TITLE]").size(36).bold())
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Subtitle or Description]").size(24))
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_break(BreakType::TextWrapping))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("Prepared by:").size(20))
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Author Name]").size(20))
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Date]").size(20))
.align(AlignmentType::Center)
);
// Page break
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_break(BreakType::Page))
);
// Table of Contents placeholder
docx = self.add_table_of_contents(docx)?;
// Executive Summary
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("Executive Summary").size(28).bold())
.style("Heading1")
);
Ok(docx)
}
fn apply_invoice_template(&self, mut docx: Docx) -> Result<Docx> {
// Company header
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[COMPANY NAME]").size(32).bold())
.align(AlignmentType::Right)
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("INVOICE").size(28).bold())
.align(AlignmentType::Right)
);
// Invoice details table
let mut invoice_info = Table::new(vec![])
.add_row(TableRow::new(vec![
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("Invoice #:"))),
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("[INV-0001]"))),
]))
.add_row(TableRow::new(vec![
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("Date:"))),
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("[Date]"))),
]))
.add_row(TableRow::new(vec![
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("Due Date:"))),
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("[Due Date]"))),
]));
docx = docx.add_table(invoice_info);
Ok(docx)
}
fn apply_contract_template(&self, mut docx: Docx) -> Result<Docx> {
// Contract title
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[CONTRACT TYPE] AGREEMENT").size(28).bold())
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(Paragraph::new());
// Parties
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("This Agreement is entered into as of [Date] between:"))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Party 1 Name], a [Entity Type] (\"Party 1\")"))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("and"))
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Party 2 Name], a [Entity Type] (\"Party 2\")"))
);
Ok(docx)
}
fn apply_memo_template(&self, mut docx: Docx) -> Result<Docx> {
// Memo header
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("MEMORANDUM").size(24).bold())
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(Paragraph::new());
// Memo fields
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("TO: ").bold())
.add_run(Run::new().add_text("[Recipient(s)]"))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("FROM: ").bold())
.add_run(Run::new().add_text("[Sender]"))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("DATE: ").bold())
.add_run(Run::new().add_text("[Date]"))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("SUBJECT: ").bold())
.add_run(Run::new().add_text("[Subject]"))
);
// Divider line
let mut divider = Paragraph::new();
for _ in 0..70 { divider = divider.add_run(Run::new().add_text("_")); }
docx = docx.add_paragraph(divider);
Ok(docx)
}
fn apply_newsletter_template(&self, mut docx: Docx) -> Result<Docx> {
// Newsletter header
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[NEWSLETTER TITLE]").size(36).bold())
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Issue #] | [Date]").size(18))
.align(AlignmentType::Center)
);
// Two-column layout requires section APIs; skip for now
Ok(docx)
}
}
// Supporting types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DocumentTemplate {
BusinessLetter,
Resume,
Report,
Invoice,
Contract,
Memo,
Newsletter,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentProperties {
pub title: String,
pub subject: String,
pub author: String,
pub keywords: Vec<String>,
pub description: String,
pub company: Option<String>,
pub manager: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SectionConfig {
pub page_size: PageSize,
pub landscape: bool,
pub margins: Margins,
pub columns: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PageSize {
A4,
Letter,
Legal,
A3,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Margins {
pub top: f32,
pub bottom: f32,
pub left: f32,
pub right: f32,
pub header: f32,
pub footer: f32,
}
impl Default for Margins {
fn default() -> Self {
Self {
top: 25.4, // 1 inch in mm
bottom: 25.4,
left: 25.4,
right: 25.4,
header: 12.7, // 0.5 inch
footer: 12.7,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ChartType {
Bar,
Column,
Line,
Pie,
Area,
Scatter,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChartData {
pub title: String,
pub categories: Vec<String>,
pub series: Vec<ChartSeries>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChartSeries {
pub name: String,
pub values: Vec<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum WatermarkStyle {
Diagonal,
Horizontal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomStyle {
pub id: String,
pub name: String,
pub based_on: Option<String>,
pub font: Option<String>,
pub size: Option<usize>,
pub bold: bool,
pub italic: bool,
pub color: Option<String>,
pub spacing: Option<StyleSpacing>,
pub indent: Option<StyleIndent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StyleSpacing {
pub before: i32,
pub after: i32,
pub line: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StyleIndent {
pub left: i32,
pub right: i32,
pub first_line: i32,
}
-45
View File
@@ -1,45 +0,0 @@
use std::fs::{self, File};
use std::path::PathBuf;
use anyhow::Result;
use docx_rs::{Docx, Paragraph, Run, Pic, BreakType};
fn main() -> Result<()> {
// Generate a simple 100x100 PNG in-memory (red square)
let width = 100u32;
let height = 100u32;
let mut img = ::image::RgbaImage::new(width, height);
for y in 0..height {
for x in 0..width {
img.put_pixel(x, y, ::image::Rgba([255, 0, 0, 255]));
}
}
let mut png_bytes: Vec<u8> = Vec::new();
let dyn_img = ::image::DynamicImage::ImageRgba8(img);
dyn_img.write_to(&mut std::io::Cursor::new(&mut png_bytes), ::image::ImageFormat::Png)?;
// Build a DOCX with an image and a caption
let mut docx = Docx::new();
let para = Paragraph::new()
.add_run(Run::new().add_text("Embedded image demo").bold().size(28))
.add_run(Run::new().add_break(BreakType::TextWrapping));
docx = docx.add_paragraph(para);
let image_para = Paragraph::new().add_run({
let run = Run::new();
run.add_image(Pic::new_with_dimensions(png_bytes, width, height))
});
docx = docx.add_paragraph(image_para);
// Ensure output directory exists
let out_dir = PathBuf::from("example/output");
fs::create_dir_all(&out_dir)?;
let out_path = out_dir.join("embed_image.docx");
let file = File::create(&out_path)?;
docx.build().pack(file)?;
println!("Wrote {}", out_path.display());
Ok(())
}
-468
View File
@@ -1,468 +0,0 @@
use anyhow::{Context, Result};
use ::image::{ImageFormat};
use printpdf::*;
use dotext::MsDoc;
use ::lopdf::{dictionary, Object, ObjectId, Document as LoDocument};
use std::fs::{self, File};
use std::io::{BufWriter, Read};
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::NamedTempFile;
use tracing::{debug, info};
use crate::pure_converter::PureRustConverter;
pub struct DocumentConverter {
pure_converter: PureRustConverter,
prefer_external_tools: bool,
}
impl DocumentConverter {
pub fn new() -> Self {
Self {
pure_converter: PureRustConverter::new(),
prefer_external_tools: cfg!(feature = "hi-fidelity"), // Prefer external/hi-fi if feature enabled
}
}
pub fn docx_to_pdf(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> {
if self.prefer_external_tools {
// Try external tools first if preferred
// Method 1: Try LibreOffice if available
if self.try_libreoffice_conversion(docx_path, pdf_path).is_ok() {
info!("Successfully converted DOCX to PDF using LibreOffice");
return Ok(());
}
// Method 2: Try unoconv if available
if self.try_unoconv_conversion(docx_path, pdf_path).is_ok() {
info!("Successfully converted DOCX to PDF using unoconv");
return Ok(());
}
}
// Use pure Rust implementation (default)
self.pure_converter.docx_to_pdf_pure(docx_path, pdf_path)?;
info!("Successfully converted DOCX to PDF using pure Rust implementation");
Ok(())
}
/// Convert with explicit preference overriding internal default
pub fn docx_to_pdf_with_preference(&self, docx_path: &Path, pdf_path: &Path, prefer_external: bool) -> Result<()> {
if prefer_external {
if self.try_libreoffice_conversion(docx_path, pdf_path).is_ok() {
info!("Successfully converted DOCX to PDF using LibreOffice (explicit preference)");
return Ok(());
}
if self.try_unoconv_conversion(docx_path, pdf_path).is_ok() {
info!("Successfully converted DOCX to PDF using unoconv (explicit preference)");
return Ok(());
}
}
// Fallback to pure implementation
self.pure_converter.docx_to_pdf_pure(docx_path, pdf_path)?;
info!("Successfully converted DOCX to PDF using pure Rust implementation (explicit preference)");
Ok(())
}
fn try_libreoffice_conversion(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> {
let output = Command::new("libreoffice")
.args(&[
"--headless",
"--invisible",
"--nodefault",
"--nolockcheck",
"--nologo",
"--norestore",
"--convert-to",
"pdf",
"--outdir",
pdf_path.parent().unwrap().to_str().unwrap(),
docx_path.to_str().unwrap(),
])
.output();
match output {
Ok(output) if output.status.success() => {
// LibreOffice creates the PDF with the same base name
let temp_pdf = pdf_path.parent().unwrap()
.join(docx_path.file_stem().unwrap())
.with_extension("pdf");
if temp_pdf != pdf_path {
fs::rename(&temp_pdf, pdf_path)?;
}
Ok(())
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("LibreOffice conversion failed: {}", stderr)
}
Err(e) => {
debug!("LibreOffice not available: {}", e);
anyhow::bail!("LibreOffice not available")
}
}
}
fn try_unoconv_conversion(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> {
let output = Command::new("unoconv")
.args(&[
"-f", "pdf",
"-o", pdf_path.to_str().unwrap(),
docx_path.to_str().unwrap(),
])
.output();
match output {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("unoconv conversion failed: {}", stderr)
}
Err(e) => {
debug!("unoconv not available: {}", e);
anyhow::bail!("unoconv not available")
}
}
}
fn basic_docx_to_pdf(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> {
// Extract text from DOCX (fallback using dotext)
let mut reader = dotext::Docx::open(docx_path)
.with_context(|| format!("Failed to open DOCX {:?}", docx_path))?;
let mut data = String::new();
use std::io::Read as _;
reader.read_to_string(&mut data)?;
let text = data;
// Create a basic PDF with the extracted text
let (doc, page1, layer1) = PdfDocument::new("Document", Mm(210.0), Mm(297.0), "Layer 1");
let _current_layer = doc.get_page(page1).get_layer(layer1);
// Load a basic font
let font = doc.add_builtin_font(BuiltinFont::Helvetica)?;
// Split text into lines and add to PDF
let lines: Vec<&str> = text.lines().collect();
let mut y_position = Mm(280.0);
let line_height = Mm(5.0);
let mut current_layer = doc.get_page(page1).get_layer(layer1);
for line in lines {
if y_position < Mm(20.0) {
let (page, layer) = doc.add_page(Mm(210.0), Mm(297.0), "Page layer");
current_layer = doc.get_page(page).get_layer(layer);
y_position = Mm(280.0);
}
current_layer.use_text(line, 12.0, Mm(10.0), y_position, &font);
y_position -= line_height;
}
doc.save(&mut BufWriter::new(File::create(pdf_path)?))?;
Ok(())
}
pub fn pdf_to_images(
&self,
pdf_path: &Path,
output_dir: &Path,
format: ImageFormat,
dpi: u32,
) -> Result<Vec<PathBuf>> {
// Try multiple methods for PDF to image conversion
// Method 1: Try pdftoppm if available
if let Ok(images) = self.try_pdftoppm_conversion(pdf_path, output_dir, format, dpi) {
info!("Successfully converted PDF to images using pdftoppm");
return Ok(images);
}
// Method 2: Try ImageMagick if available
if let Ok(images) = self.try_imagemagick_conversion(pdf_path, output_dir, format, dpi) {
info!("Successfully converted PDF to images using ImageMagick");
return Ok(images);
}
// Method 3: Try Ghostscript if available
if let Ok(images) = self.try_ghostscript_conversion(pdf_path, output_dir, format, dpi) {
info!("Successfully converted PDF to images using Ghostscript");
return Ok(images);
}
anyhow::bail!("No PDF to image converter available. Please install pdftoppm, ImageMagick, or Ghostscript")
}
fn try_pdftoppm_conversion(
&self,
pdf_path: &Path,
output_dir: &Path,
format: ImageFormat,
dpi: u32,
) -> Result<Vec<PathBuf>> {
fs::create_dir_all(output_dir)?;
let output_prefix = output_dir.join("page");
let format_arg = match format {
ImageFormat::Png => "-png",
ImageFormat::Jpeg => "-jpeg",
_ => "-png",
};
let output = Command::new("pdftoppm")
.args(&[
format_arg,
"-r", &dpi.to_string(),
pdf_path.to_str().unwrap(),
output_prefix.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("pdftoppm failed: {}", stderr);
}
// Collect generated image files
let extension = match format {
ImageFormat::Png => "png",
ImageFormat::Jpeg => "jpg",
_ => "png",
};
let mut images = Vec::new();
for entry in fs::read_dir(output_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension() == Some(std::ffi::OsStr::new(extension)) {
images.push(path);
}
}
images.sort();
Ok(images)
}
fn try_imagemagick_conversion(
&self,
pdf_path: &Path,
output_dir: &Path,
format: ImageFormat,
dpi: u32,
) -> Result<Vec<PathBuf>> {
fs::create_dir_all(output_dir)?;
let extension = match format {
ImageFormat::Png => "png",
ImageFormat::Jpeg => "jpg",
_ => "png",
};
let output_pattern = output_dir.join(format!("page-%03d.{}", extension));
let output = Command::new("convert")
.args(&[
"-density", &dpi.to_string(),
pdf_path.to_str().unwrap(),
"-quality", "100",
output_pattern.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("ImageMagick convert failed: {}", stderr);
}
// Collect generated image files
let mut images = Vec::new();
for entry in fs::read_dir(output_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension() == Some(std::ffi::OsStr::new(extension)) {
images.push(path);
}
}
images.sort();
Ok(images)
}
fn try_ghostscript_conversion(
&self,
pdf_path: &Path,
output_dir: &Path,
format: ImageFormat,
dpi: u32,
) -> Result<Vec<PathBuf>> {
fs::create_dir_all(output_dir)?;
let device = match format {
ImageFormat::Png => "png16m",
ImageFormat::Jpeg => "jpeg",
_ => "png16m",
};
let extension = match format {
ImageFormat::Png => "png",
ImageFormat::Jpeg => "jpg",
_ => "png",
};
let output_pattern = output_dir.join(format!("page-%03d.{}", extension));
let output = Command::new("gs")
.args(&[
"-dNOPAUSE",
"-dBATCH",
"-sDEVICE", device,
&format!("-r{}", dpi),
"-dTextAlphaBits=4",
"-dGraphicsAlphaBits=4",
&format!("-sOutputFile={}", output_pattern.to_str().unwrap()),
pdf_path.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Ghostscript failed: {}", stderr);
}
// Collect generated image files
let mut images = Vec::new();
for entry in fs::read_dir(output_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension() == Some(std::ffi::OsStr::new(extension)) {
images.push(path);
}
}
images.sort();
Ok(images)
}
pub fn docx_to_images(
&self,
docx_path: &Path,
output_dir: &Path,
format: ImageFormat,
dpi: u32,
) -> Result<Vec<PathBuf>> {
// First convert DOCX to PDF
let temp_pdf = NamedTempFile::new()?.into_temp_path();
self.docx_to_pdf(docx_path, &temp_pdf)?;
// Then convert PDF to images
let images = self.pdf_to_images(&temp_pdf, output_dir, format, dpi)?;
Ok(images)
}
pub fn docx_to_images_with_preference(
&self,
docx_path: &Path,
output_dir: &Path,
format: ImageFormat,
dpi: u32,
prefer_external: bool,
) -> Result<Vec<PathBuf>> {
let temp_pdf = NamedTempFile::new()?.into_temp_path();
self.docx_to_pdf_with_preference(docx_path, &temp_pdf, prefer_external)?;
let images = self.pdf_to_images(&temp_pdf, output_dir, format, dpi)?;
Ok(images)
}
pub fn create_thumbnail(
&self,
image_path: &Path,
output_path: &Path,
width: u32,
height: u32,
) -> Result<()> {
let img = ::image::open(image_path)
.with_context(|| format!("Failed to open image {:?}", image_path))?;
let thumbnail = img.thumbnail(width, height);
thumbnail.save(output_path)
.with_context(|| format!("Failed to save thumbnail to {:?}", output_path))?;
info!("Created thumbnail {}x{} at {:?}", width, height, output_path);
Ok(())
}
pub fn merge_pdfs(&self, pdf_paths: &[PathBuf], output_path: &Path) -> Result<()> {
// Try using pdftk if available
if self.try_pdftk_merge(pdf_paths, output_path).is_ok() {
info!("Successfully merged PDFs using pdftk");
return Ok(());
}
// Fallback to lopdf for merging
self.merge_pdfs_with_lopdf(pdf_paths, output_path)?;
info!("Successfully merged PDFs using lopdf");
Ok(())
}
fn try_pdftk_merge(&self, pdf_paths: &[PathBuf], output_path: &Path) -> Result<()> {
let mut args = Vec::new();
for path in pdf_paths {
args.push(path.to_str().unwrap());
}
args.push("cat");
args.push("output");
args.push(output_path.to_str().unwrap());
let output = Command::new("pdftk")
.args(&args)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("pdftk merge failed: {}", stderr);
}
Ok(())
}
fn merge_pdfs_with_lopdf(&self, pdf_paths: &[PathBuf], output_path: &Path) -> Result<()> {
let mut merged = LoDocument::new();
merged.version = "1.5".to_string();
for pdf_path in pdf_paths {
let mut doc = LoDocument::load(pdf_path)?;
// Merge pages
for page_id in doc.get_pages().values() {
merged.add_object(doc.get_object(*page_id)?.clone());
}
}
merged.save(output_path)?;
Ok(())
}
pub fn split_pdf(&self, pdf_path: &Path, output_dir: &Path) -> Result<Vec<PathBuf>> {
fs::create_dir_all(output_dir)?;
let doc = LoDocument::load(pdf_path)?;
let pages = doc.get_pages();
let mut output_paths = Vec::new();
for (i, (_, page_id)) in pages.iter().enumerate() {
let mut single_page = LoDocument::new();
single_page.version = doc.version.clone();
// Clone the page to the new document
single_page.add_object(doc.get_object(*page_id)?.clone());
let output_path = output_dir.join(format!("page_{:03}.pdf", i + 1));
single_page.save(&output_path)?;
output_paths.push(output_path);
}
info!("Split PDF into {} pages", output_paths.len());
Ok(output_paths)
}
}
-1813
View File
File diff suppressed because it is too large Load Diff
+933
View File
@@ -0,0 +1,933 @@
from __future__ import annotations
import base64
import json
import os
import re
import tempfile
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Union
import regex as re_lib
from docx import Document
from docx.shared import Inches, Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from PIL import Image as PILImage
from io import BytesIO
from security import SecurityConfig
def file_to_result(path: str, return_content: bool = False) -> Any:
"""
Helper: if return_content is True, read file and return {path, size, content_base64}.
Otherwise return {path, size}.
"""
size = os.path.getsize(path)
if not return_content:
return {"path": path, "size": size}
with open(path, "rb") as f:
data = f.read()
b64 = base64.b64encode(data).decode("utf-8")
return {"path": path, "size": len(data), "content_base64": b64}
@dataclass
class DocumentMetadata:
document_id: str
path: str
name: str
size: int
pages: int
class DocxToolsProvider:
def __init__(
self,
security_config: SecurityConfig,
templates_dir: str,
):
self.security_config = security_config
self.templates_dir = templates_dir
self.documents: Dict[str, Any] = {}
self._temp_base = tempfile.mkdtemp(prefix="py_docx_mcp_")
# ---- basic lifecycle ----
def create_document(self) -> str:
doc_id = str(uuid.uuid4())
path = os.path.join(self._temp_base, f"{doc_id}.docx")
doc = Document()
doc.save(path)
self.documents[doc_id] = {
"doc": doc,
"path": path,
"name": "Untitled",
}
return doc_id
def open_document(self, path: str) -> str:
if not os.path.isfile(path):
raise ValueError(f"File not found: {path}")
doc_id = str(uuid.uuid4())
doc = Document(path)
self.documents[doc_id] = {
"doc": doc,
"path": path,
"name": os.path.basename(path),
}
return doc_id
def get_doc(self, document_id: str) -> Document:
entry = self.documents.get(document_id)
if not entry:
raise ValueError(f"Document not found: {document_id}")
return entry["doc"]
def list_documents(self) -> List[Dict[str, Any]]:
out = []
for doc_id, info in self.documents.items():
out.append({
"document_id": doc_id,
"name": info["name"],
"path": info["path"],
})
return out
def close_document(self, document_id: str) -> None:
if document_id not in self.documents:
raise ValueError("Document not found")
del self.documents[document_id]
# ---- content operations ----
def add_paragraph(
self,
document_id: str,
text: str,
style: Dict[str, Any],
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
p = doc.add_paragraph(text)
run = p.runs[0] if p.runs else p.add_run()
font_family = style.get("font_family")
font_size = style.get("font_size")
bold = style.get("bold")
italic = style.get("italic")
underline = style.get("underline")
color = style.get("color")
alignment = style.get("alignment")
if font_family:
run.font.name = font_family
if font_size:
run.font.size = Pt(font_size)
if bold is not None:
run.bold = bool(bold)
if italic is not None:
run.italic = bool(italic)
if underline is not None:
run.underline = bool(underline)
if color:
try:
run.font.color.rgb = RGBColor.from_string(color)
except Exception:
pass
if alignment:
align = alignment.lower()
if align == "center":
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
elif align == "right":
p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
elif align == "justify":
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
return self._maybe_return_doc(document_id, return_content)
def add_heading(
self,
document_id: str,
text: str,
level: int,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
level = max(0, min(6, level))
doc.add_heading(text, level=level)
return self._maybe_return_doc(document_id, return_content)
def add_table(
self,
document_id: str,
rows: List[List[str]],
headers: Optional[List[str]] = None,
border_style: Optional[str] = None,
col_widths: Optional[List[int]] = None,
cell_shading: Optional[str] = None,
merges: Optional[List[Dict[str, int]]] = None,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
rows = rows or []
if headers:
rows = [headers] + rows
if not rows:
return self._maybe_return_doc(document_id, return_content)
table = doc.add_table(rows=len(rows), cols=len(rows[0]))
for ri, row in enumerate(rows):
for ci, val in enumerate(row):
table.cell(ri, ci).text = str(val or "")
if merges:
for m in merges:
r = m.get("row", 0)
c = m.get("col", 0)
row_span = m.get("row_span", 1)
col_span = m.get("col_span", 1)
if row_span > 1 or col_span > 1:
table.cell(r, c).merge(
table.cell(r + row_span - 1, c + col_span - 1)
)
return self._maybe_return_doc(document_id, return_content)
def add_section_break(
self,
document_id: str,
page_size: Optional[str] = None,
orientation: Optional[str] = None,
margins: Optional[Dict[str, float]] = None,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
doc.add_page_break()
return self._maybe_return_doc(document_id, return_content)
def add_list(
self,
document_id: str,
items: List[str],
ordered: bool,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
prefix = "1. " if ordered else "- "
for item in items:
doc.add_paragraph(f"{prefix}{item}")
return self._maybe_return_doc(document_id, return_content)
def add_list_item(
self,
document_id: str,
text: str,
level: int,
ordered: bool,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
indent = " " * level
prefix = "1. " if ordered else "- "
doc.add_paragraph(f"{indent}{prefix}{text}")
return self._maybe_return_doc(document_id, return_content)
def add_page_break(
self,
document_id: str,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
doc.add_page_break()
return self._maybe_return_doc(document_id, return_content)
def insert_toc(
self,
document_id: str,
from_level: int = 1,
to_level: int = 3,
right_align_dots: bool = True,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
doc.add_paragraph("Table of Contents (placeholder)")
return self._maybe_return_doc(document_id, return_content)
def insert_bookmark_after_heading(
self,
document_id: str,
heading_text: str,
name: str,
return_content: bool = False,
) -> Any:
# python-docx does not expose bookmarks easily; placeholder.
return self._maybe_return_doc(document_id, return_content)
def set_header(
self,
document_id: str,
text: str,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
section = doc.sections[0]
header = section.header
header.paragraphs[0].text = text
return self._maybe_return_doc(document_id, return_content)
def set_footer(
self,
document_id: str,
text: str,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
section = doc.sections[0]
footer = section.footer
footer.paragraphs[0].text = text
return self._maybe_return_doc(document_id, return_content)
def set_page_numbering(
self,
document_id: str,
location: str,
template: Optional[str] = None,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
section = doc.sections[0]
target = section.footer if location == "footer" else section.header
target.paragraphs[0].text = template or "Page {PAGE} of {PAGES}"
return self._maybe_return_doc(document_id, return_content)
def embed_page_number_fields(
self,
document_id: str,
return_content: bool = False,
) -> Any:
# python-docx cannot easily inject raw field codes; no-op placeholder.
return self._maybe_return_doc(document_id, return_content)
def add_image(
self,
document_id: str,
data_base64: str,
width: Optional[int] = None,
height: Optional[int] = None,
alt_text: Optional[str] = None,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
img_data = base64.b64decode(data_base64)
img = PILImage.open(BytesIO(img_data))
tmp_path = "/tmp/py_docx_temp.png"
img.save(tmp_path, format="PNG")
doc.add_picture(
tmp_path,
width=Inches(width / 96.0) if width else None,
height=Inches(height / 96.0) if height else None,
)
return self._maybe_return_doc(document_id, return_content)
def add_hyperlink(
self,
document_id: str,
text: str,
url: str,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
doc.add_paragraph(f"{text} ({url})")
return self._maybe_return_doc(document_id, return_content)
def find_and_replace(
self,
document_id: str,
find_text: str,
replace_text: str,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
count = 0
for para in doc.paragraphs:
if find_text in para.text:
para.text = para.text.replace(find_text, replace_text)
count += para.text.count(replace_text)
return {
"success": True,
"replacements": count,
"document": self._maybe_return_doc(document_id, return_content),
}
def find_and_replace_advanced(
self,
document_id: str,
pattern: str,
replacement: str,
case_sensitive: bool,
whole_word: bool,
use_regex: bool,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
count = 0
for para in doc.paragraphs:
original = para.text
if use_regex:
flags = 0 if case_sensitive else re_lib.IGNORECASE
pat = pattern
else:
if whole_word:
pat = r"\b" + re_lib.escape(pattern) + r"\b"
else:
pat = re_lib.escape(pattern)
flags = 0 if case_sensitive else re_lib.IGNORECASE
new_text, n = re_lib.subn(pat, replacement, original, flags=flags)
if new_text != original:
para.text = new_text
count += n
return {
"success": True,
"replacements": count,
"document": self._maybe_return_doc(document_id, return_content),
}
def apply_paragraph_format(
self,
document_id: str,
contains: Optional[str],
format: Dict[str, Any],
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
updated = 0
for para in doc.paragraphs:
if contains and (contains not in para.text):
continue
for run in para.runs or []:
if "font_family" in format:
run.font.name = format["font_family"]
if "font_size" in format:
run.font.size = Pt(int(format["font_size"]))
if "bold" in format:
run.bold = bool(format["bold"])
if "italic" in format:
run.italic = bool(format["italic"])
if "underline" in format:
run.underline = bool(format["underline"])
if "color" in format:
try:
run.font.color.rgb = RGBColor.from_string(format["color"])
except Exception:
pass
updated += 1
return {
"success": True,
"paragraphs_updated": updated,
"document": self._maybe_return_doc(document_id, return_content),
}
def extract_text(self, document_id: str) -> str:
doc = self.get_doc(document_id)
return "\n".join(p.text for p in doc.paragraphs)
def get_tables(self, document_id: str) -> List[Dict[str, Any]]:
doc = self.get_doc(document_id)
out = []
for idx, table in enumerate(doc.tables):
rows_data = []
for row in table.rows:
cells = [cell.text for cell in row.cells]
rows_data.append(cells)
out.append({
"index": idx,
"rows": len(table.rows),
"cols": len(table.columns),
"data": rows_data,
})
return out
def list_images(self, document_id: str) -> List[Dict[str, Any]]:
return []
def list_hyperlinks(self, document_id: str) -> List[Dict[str, Any]]:
doc = self.get_doc(document_id)
links = []
for p in doc.paragraphs:
for m in re.finditer(r"\((https?://\S+)\)", p.text):
links.append({"text": p.text.strip(), "url": m.group(1)})
return links
def get_fields_summary(self, document_id: str) -> Dict[str, Any]:
return {"note": "Fields summary not fully implemented in Python version"}
def strip_personal_info(self, document_id: str) -> None:
doc = self.get_doc(document_id)
core = doc.core_properties
core.author = ""
core.last_modified_by = ""
core.revision_number = 1
def get_metadata(self, document_id: str) -> DocumentMetadata:
info = self.documents[document_id]
path = info["path"]
size = os.path.getsize(path)
doc = info["doc"]
pages = max(1, len(doc.paragraphs) // 40)
return DocumentMetadata(
document_id=document_id,
path=path,
name=info["name"],
size=size,
pages=pages,
)
def save_document(
self,
document_id: str,
output_path: str,
return_content: bool = True,
) -> Any:
info = self.documents[document_id]
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
info["doc"].save(output_path)
return file_to_result(output_path, return_content=return_content)
# ---- conversion (best-effort, external tools optional) ----
def convert_to_pdf(
self,
document_id: str,
output_path: str,
prefer_external: bool = False,
return_content: bool = True,
) -> Any:
meta = self.get_metadata(document_id)
# If external tools are allowed, you can call LibreOffice here;
# for now, we indicate requirement.
if prefer_external:
raise NotImplementedError(
"External PDF conversion not yet wired; "
"configure LibreOffice/unoconv integration."
)
raise NotImplementedError(
"PDF conversion not yet implemented in pure Python version."
)
def export_pdf_with_field_refresh(
self,
document_id: str,
output_path: str,
prefer_external: bool = True,
return_content: bool = True,
) -> Any:
self.embed_page_number_fields(document_id)
return self.convert_to_pdf(document_id, output_path, prefer_external, return_content=return_content)
def convert_to_images(
self,
document_id: str,
output_dir: str,
format: str = "png",
dpi: int = 150,
return_content: bool = True,
) -> Any:
raise NotImplementedError(
"Image conversion not yet implemented in pure Python version."
)
def convert_to_images_with_preference(
self,
document_id: str,
output_dir: str,
format: str = "png",
dpi: int = 150,
prefer_external: bool = True,
return_content: bool = True,
) -> Any:
return self.convert_to_images(document_id, output_dir, format, dpi, return_content=return_content)
# ---- advanced docx operations ----
def merge_documents(
self,
document_ids: List[str],
output_path: str,
return_content: bool = True,
) -> Any:
merged = Document()
for did in document_ids:
doc = self.get_doc(did)
for elem in doc.element.body:
merged.element.body.append(elem)
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
merged.save(output_path)
return file_to_result(output_path, return_content=return_content)
def split_document(
self,
document_id: str,
output_dir: str,
return_content: bool = True,
) -> Any:
# Very naive: split by page breaks.
doc = self.get_doc(document_id)
os.makedirs(output_dir, exist_ok=True)
parts: List[Document] = []
current = Document()
for elem in doc.element.body:
tag = elem.tag
if "lastRenderedPageBreak" in tag or "pageBreakBefore" in tag:
parts.append(current)
current = Document()
else:
current.element.body.append(elem)
if len(current.element.body) > 0:
parts.append(current)
results = []
for i, pdoc in enumerate(parts):
path = os.path.join(output_dir, f"part_{i}.docx")
pdoc.save(path)
results.append(file_to_result(path, return_content=return_content))
return {"parts": results}
def get_document_structure(self, document_id: str) -> Dict[str, Any]:
doc = self.get_doc(document_id)
headings = []
for p in doc.paragraphs:
if p.style.name.startswith("Heading"):
headings.append({
"text": p.text,
"style": p.style.name,
})
return {
"headings": headings,
"paragraph_count": len(doc.paragraphs),
"table_count": len(doc.tables),
}
def get_outline(self, document_id: str) -> List[Dict[str, Any]]:
return self.get_document_structure(document_id).get("headings", [])
def get_ranges(self, document_id: str, selector: str) -> List[Dict[str, Any]]:
# Minimal: "heading:'Text'" or "paragraph[i]"
doc = self.get_doc(document_id)
ranges = []
if selector.startswith("heading:"):
target = selector[len("heading:"):].strip().strip("'\"")
for i, p in enumerate(doc.paragraphs):
if p.style.name.startswith("Heading") and target.lower() in p.text.lower():
ranges.append({"type": "paragraph", "index": i})
elif selector.startswith("paragraph["):
m = re.match(r"paragraph\[(\d+)\]", selector)
if m:
idx = int(m.group(1))
ranges.append({"type": "paragraph", "index": idx})
return ranges
def replace_range_text(
self,
document_id: str,
range_id: Dict[str, Any],
text: str,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
if range_id.get("type") == "paragraph":
idx = range_id.get("index")
if 0 <= idx < len(doc.paragraphs):
doc.paragraphs[idx].text = text
return self._maybe_return_doc(document_id, return_content)
def set_table_cell_text(
self,
document_id: str,
table_index: int,
row: int,
col: int,
text: str,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
table = doc.tables[table_index]
table.cell(row, col).text = text
return self._maybe_return_doc(document_id, return_content)
def get_document_properties(self, document_id: str) -> Dict[str, Any]:
doc = self.get_doc(document_id)
core = doc.core_properties
return {
"title": core.title,
"subject": core.subject,
"author": core.author,
"last_modified_by": core.last_modified_by,
"created": str(core.created),
"modified": str(core.modified),
}
def set_document_properties(
self,
document_id: str,
title: Optional[str],
subject: Optional[str],
author: Optional[str],
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
core = doc.core_properties
if title is not None:
core.title = title
if subject is not None:
core.subject = subject
if author is not None:
core.author = author
return self._maybe_return_doc(document_id, return_content)
def insert_after_heading(
self,
document_id: str,
heading_text: str,
text: str,
return_content: bool = False,
) -> Any:
doc = self.get_doc(document_id)
for p in doc.paragraphs:
if p.style.name.startswith("Heading") and heading_text.lower() in p.text.lower():
doc.add_paragraph(text)
return self._maybe_return_doc(document_id, return_content)
return {"success": False, "reason": "Heading not found"}
def sanitize_external_links(self, document_id: str) -> None:
# naive: remove URLs from text
doc = self.get_doc(document_id)
for p in doc.paragraphs:
p.text = re.sub(r"(https?://\S+)", "", p.text)
def redact_text(
self,
document_id: str,
pattern: str,
use_regex: bool = False,
whole_word: bool = False,
case_sensitive: bool = False,
return_content: bool = False,
) -> Any:
result = self.find_and_replace_advanced(
document_id,
pattern=pattern,
replacement="",
case_sensitive=case_sensitive,
whole_word=whole_word,
use_regex=use_regex,
return_content=return_content,
)
return result
def analyze_formatting(self, document_id: str) -> Dict[str, Any]:
doc = self.get_doc(document_id)
styles = set()
fonts = set()
for p in doc.paragraphs:
styles.add(p.style.name)
for run in p.runs or []:
if run.font.name:
fonts.add(run.font.name)
return {
"styles_used": list(styles),
"fonts_detected": list(fonts),
"has_tables": len(doc.tables) > 0,
"has_images": False,
"has_hyperlinks": any(
"http" in p.text.lower() for p in doc.paragraphs
),
"page_count": max(1, len(doc.paragraphs) // 40),
"section_count": len(doc.sections),
}
def get_word_count(self, document_id: str) -> Dict[str, Any]:
text = self.extract_text(document_id)
words = text.split()
chars = len(text)
chars_no_spaces = len(text.replace(" ", ""))
paragraphs = len([l for l in text.splitlines() if l.strip()])
sentences = len(re.findall(r"[.!?]+", text))
return {
"words": len(words),
"characters": chars,
"characters_no_spaces": chars_no_spaces,
"paragraphs": paragraphs,
"sentences": sentences,
"pages": max(1, len(words) // 250),
"reading_time_minutes": max(1, len(words) // 200),
}
def search_text(
self,
document_id: str,
search_term: str,
case_sensitive: bool = False,
whole_word: bool = False,
) -> Dict[str, Any]:
text = self.extract_text(document_id)
if not case_sensitive:
text_lower = text.lower()
term_lower = search_term.lower()
else:
text_lower = text
term_lower = search_term
if whole_word:
pattern = r"\b" + re_lib.escape(term_lower) + r"\b"
else:
pattern = re_lib.escape(term_lower)
matches = []
for m in re_lib.finditer(pattern, text_lower):
start = max(0, m.start() - 50)
end = min(len(text), m.end() + 50)
line = text[: m.start()].count("\n") + 1
matches.append({
"position": m.start(),
"context": text[start:end],
"line": line,
})
return {
"matches": matches,
"total_matches": len(matches),
}
def export_to_markdown(
self,
document_id: str,
output_path: str,
return_content: bool = True,
) -> Any:
text = self.extract_text(document_id)
md_lines = []
for line in text.splitlines():
t = line.strip()
if not t:
md_lines.append("")
continue
if len(t) < 100 and any(c.isupper() for c in t):
if all(c.isupper() or c.isspace() for c in t):
md_lines.append(f"# {t}")
else:
md_lines.append(f"## {t}")
else:
md_lines.append(t)
md = "\n\n".join(md_lines)
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(md)
return file_to_result(output_path, return_content=return_content)
def export_to_html(
self,
document_id: str,
output_path: str,
return_content: bool = True,
) -> Any:
text = self.extract_text(document_id)
html_parts = ['<html><head><meta charset="utf-8"></head><body>\n']
for line in text.splitlines():
t = line.strip()
if not t:
continue
if len(t) < 100 and any(c.isupper() for c in t):
if all(c.isupper() or c.isspace() for c in t):
html_parts.append(f"<h1>{t}</h1>")
else:
html_parts.append(f"<h2>{t}</h2>")
elif t.startswith("- ") or t.startswith("* "):
html_parts.append(f"<ul><li>{t[2:]}</li></ul>")
else:
html_parts.append(f"<p>{t}</p>")
html_parts.append("</body></html>\n")
html = "\n".join(html_parts)
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(html)
return file_to_result(output_path, return_content=return_content)
# ---- security and storage info ----
def get_security_info(self) -> Dict[str, Any]:
return {
"readonly_mode": self.security_config.readonly_mode,
"sandbox_mode": self.security_config.sandbox_mode,
"allow_external_tools": self.security_config.allow_external_tools,
"allow_network": self.security_config.allow_network,
"max_document_size": self.security_config.max_document_size,
"max_open_documents": self.security_config.max_open_documents,
}
def get_storage_info(self) -> Dict[str, Any]:
total = 0
for info in self.documents.values():
try:
total += os.path.getsize(info["path"])
except OSError:
pass
return {
"temp_base": self._temp_base,
"open_documents": len(self.documents),
"total_size_bytes": total,
}
# ---- templates ----
def open_template(self, name: str, templates_dir: str) -> str:
path = os.path.join(templates_dir, name)
if not os.path.isfile(path):
raise ValueError(f"Template not found: {name}")
return self.open_document(path)
def generate_from_template(
self,
template_name: str,
output_path: str,
fields: Dict[str, str],
return_content: bool = True,
) -> Any:
template_path = os.path.join(self.templates_dir, template_name)
if not os.path.isfile(template_path):
raise ValueError(f"Template not found: {template_name}")
doc_id = self.open_document(template_path)
for key, value in fields.items():
placeholder = "{{" + key + "}}"
self.find_and_replace_advanced(
doc_id,
pattern=placeholder,
replacement=str(value),
case_sensitive=False,
whole_word=True,
use_regex=False,
return_content=False,
)
self.save_document(doc_id, output_path, return_content=False)
self.close_document(doc_id)
return file_to_result(output_path, return_content=return_content)
# ---- internal helper ----
def _maybe_return_doc(
self,
document_id: str,
return_content: bool,
) -> Any:
"""
If return_content is True, save the current document in-memory state
to its path and return base64 content.
"""
if not return_content:
return {"success": True, "document_id": document_id}
info = self.documents[document_id]
info["doc"].save(info["path"])
return file_to_result(info["path"], return_content=True)
-1991
View File
File diff suppressed because it is too large Load Diff
-50
View File
@@ -1,50 +0,0 @@
use once_cell::sync::Lazy;
// Conditionally embed fonts if they exist
// If fonts don't exist, we'll use empty placeholders and rely on PDF built-in fonts
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
pub static LIBERATION_SANS_REGULAR: &[u8] = include_bytes!("../assets/fonts/LiberationSans-Regular.ttf");
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
pub static LIBERATION_SANS_REGULAR: &[u8] = &[];
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
pub static LIBERATION_SANS_BOLD: &[u8] = include_bytes!("../assets/fonts/LiberationSans-Bold.ttf");
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
pub static LIBERATION_SANS_BOLD: &[u8] = &[];
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
pub static LIBERATION_SANS_ITALIC: &[u8] = include_bytes!("../assets/fonts/LiberationSans-Italic.ttf");
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
pub static LIBERATION_SANS_ITALIC: &[u8] = &[];
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
pub static LIBERATION_MONO_REGULAR: &[u8] = include_bytes!("../assets/fonts/LiberationMono-Regular.ttf");
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
pub static LIBERATION_MONO_REGULAR: &[u8] = &[];
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
pub const EMBEDDED_FONT_REGULAR: &[u8] = include_bytes!("../assets/fonts/NotoSans-Regular.ttf");
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
pub const EMBEDDED_FONT_REGULAR: &[u8] = &[];
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
pub const EMBEDDED_FONT_BOLD: &[u8] = include_bytes!("../assets/fonts/NotoSans-Bold.ttf");
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
pub const EMBEDDED_FONT_BOLD: &[u8] = &[];
pub struct EmbeddedFonts {
pub regular: &'static [u8],
pub bold: &'static [u8],
pub italic: &'static [u8],
pub mono: &'static [u8],
}
pub static FONTS: Lazy<EmbeddedFonts> = Lazy::new(|| {
EmbeddedFonts {
regular: LIBERATION_SANS_REGULAR,
bold: LIBERATION_SANS_BOLD,
italic: LIBERATION_SANS_ITALIC,
mono: LIBERATION_MONO_REGULAR,
}
});
-128
View File
@@ -1,128 +0,0 @@
use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Read;
use std::path::Path;
const FONTS_DIR: &str = "assets/fonts";
// Pin sources and expected checksums
const LIBERATION_VERSION: &str = "2.1.5";
const LIBERATION_TAR_URL: &str = "https://github.com/liberationfonts/liberation-fonts/files/7261482/liberation-fonts-ttf-2.1.5.tar.gz";
const NOTO_BASE_URL: &str = "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSans";
const FONT_FILES: &[(&str, Option<&str>)] = &[
("LiberationSans-Regular.ttf", Some("76d04c18ea243f426b7de1f3ad208e927008f961dc5945e5aad352d0dfde8ee8")),
("LiberationSans-Bold.ttf", Some("788abee4c806d660e8aee46689dd8540cd4bb98da03dcc9d171ce3efd99a9173")),
("LiberationSans-Italic.ttf", Some("e5bae5c4cde31f22142753855f4f8fb86da6ff39955ed3c0a11248b0d16948b0")),
("LiberationMono-Regular.ttf", Some("f2b83c763e8afd21709333370bed4774337fae82267937e2b5aea7e2fbd922c1")),
("NotoSans-Regular.ttf", Some("b85c38ecea8a7cfb39c24e395a4007474fa5a4fc864f6ee33309eb4948d232d5")),
("NotoSans-Bold.ttf", Some("c976e4b1b99edc88775377fcc21692ca4bfa46b6d6ca6522bfda505b28ff9d6a")),
];
pub fn download_fonts_blocking() -> Result<()> {
fs::create_dir_all(FONTS_DIR).context("create fonts dir")?;
// Download Liberation tarball
let tar_bytes = download_bytes(LIBERATION_TAR_URL)?;
extract_liberation_from_tar(&tar_bytes, Path::new(FONTS_DIR))?;
// Download Noto fonts
for name in ["NotoSans-Regular.ttf", "NotoSans-Bold.ttf"] {
let url = format!("{}/{}", NOTO_BASE_URL, name);
let bytes = download_bytes(&url)?;
let out = Path::new(FONTS_DIR).join(name);
fs::write(&out, bytes).context("write noto font")?;
// verify immediate
verify_single(&out, expected_for(name))?;
}
// Verify all fonts after extraction
verify_fonts_blocking()
}
pub fn verify_fonts_blocking() -> Result<()> {
for (name, expected_opt) in FONT_FILES {
let path = Path::new(FONTS_DIR).join(name);
if !path.exists() {
anyhow::bail!("missing font: {}", name);
}
let actual = sha256_file(&path)?;
if let Some(expected) = expected_opt {
if !actual.eq_ignore_ascii_case(expected) {
anyhow::bail!("checksum mismatch for {}: {} != {}", name, actual, expected);
}
}
}
Ok(())
}
fn download_bytes(url: &str) -> Result<Vec<u8>> {
let res = ureq::get(url).call().context("request failed")?;
let mut buf = Vec::new();
res.into_reader().read_to_end(&mut buf).context("read body")?;
Ok(buf)
}
fn extract_liberation_from_tar(tar_gz: &[u8], out_dir: &Path) -> Result<()> {
let gz = flate2::read::GzDecoder::new(tar_gz);
let mut archive = tar::Archive::new(gz);
for entry in archive.entries().context("iter entries")? {
let mut entry = entry.context("entry")?;
// Extract filename into an owned String to avoid borrowing `entry`
let filename_owned: Option<String> = {
let path_buf = entry.path().context("entry path")?;
path_buf
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
};
let Some(filename) = filename_owned.as_deref() else { continue };
match filename {
"LiberationSans-Regular.ttf" |
"LiberationSans-Bold.ttf" |
"LiberationSans-Italic.ttf" |
"LiberationMono-Regular.ttf" => {
let dest = out_dir.join(filename);
let context_msg = format!("unpack {}", filename);
entry.unpack(&dest).context(context_msg)?;
// verify immediate
verify_single(&dest, expected_for(filename))?;
}
_ => {}
}
}
Ok(())
}
fn expected_for(name: &str) -> Option<&'static str> {
FONT_FILES.iter().find(|(n, _)| *n == name).and_then(|(_, s)| *s)
}
fn verify_single(path: &Path, expected: Option<&str>) -> Result<()> {
if let Some(exp) = expected {
let actual = sha256_file(path)?;
if !actual.eq_ignore_ascii_case(exp) {
anyhow::bail!(
"checksum mismatch for {}: {} != {}",
path.display(),
actual,
exp
);
}
}
Ok(())
}
fn sha256_file(path: &Path) -> Result<String> {
let mut file = fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 8192];
loop {
let n = file.read(&mut buf)?;
if n == 0 { break; }
hasher.update(&buf[..n]);
}
Ok(format!("{:x}", hasher.finalize()))
}
-202
View File
@@ -1,202 +0,0 @@
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;
}
})
}
-13
View File
@@ -1,13 +0,0 @@
pub mod security;
pub mod fonts_cli;
pub mod response;
// Expose primary modules for tests and external use
pub mod docx_tools;
pub mod docx_handler;
pub mod pure_converter;
pub mod converter;
#[cfg(feature = "advanced-docx")]
pub mod advanced_docx;
pub use security::{Args, SecurityConfig, SecurityMiddleware, SecurityError};
-154
View File
@@ -1,154 +0,0 @@
use anyhow::Result;
#[cfg(feature = "runtime-server")]
use mcp_server::Server;
use tracing::info;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
use clap::Parser;
#[cfg(feature = "runtime-server")]
mod docx_tools;
#[cfg(feature = "runtime-server")]
mod docx_handler;
#[cfg(feature = "runtime-server")]
mod converter;
#[cfg(feature = "runtime-server")]
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")]
mod fonts;
#[cfg(feature = "runtime-server")]
use docx_tools::DocxToolsProvider;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::registry()
.with(fmt::layer())
.with(EnvFilter::from_default_env())
.init();
// Parse command line arguments (which also includes environment variables)
let args = security::Args::parse();
// Handle top-level subcommands that should run and exit
if let Some(cmd) = &args.command {
match cmd {
security::CliCommand::Fonts { action } => {
match action {
security::FontsAction::Download => {
docx_mcp::fonts_cli::download_fonts_blocking()?;
info!("Fonts downloaded successfully");
return Ok(());
}
security::FontsAction::Verify => {
docx_mcp::fonts_cli::verify_fonts_blocking()?;
info!("Fonts verified successfully");
return Ok(());
}
}
}
}
}
// 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};
use mcp_server::router::RouterService;
use mcp_server::router::CapabilitiesBuilder;
use mcp_spec::{prompt::Prompt, resource::Resource};
use mcp_spec::protocol::ServerCapabilities;
use mcp_spec::content::Content;
use mcp_spec::tool::Tool as SpecTool;
use serde_json::Value as JsonValue;
use std::pin::Pin;
use std::future::Future;
use tokio::io::{stdin, stdout};
#[derive(Clone)]
struct DocxRouter(docx_tools::DocxToolsProvider);
impl Router for DocxRouter {
fn name(&self) -> String { "docx-mcp-server".to_string() }
fn instructions(&self) -> String { "DOCX tools for reading and exporting".to_string() }
fn capabilities(&self) -> ServerCapabilities {
CapabilitiesBuilder::new().with_tools(true).build()
}
fn list_tools(&self) -> Vec<SpecTool> {
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()
}
fn call_tool(&self, tool_name: &str, arguments: JsonValue) -> Pin<Box<dyn Future<Output = Result<Vec<Content>, mcp_spec::handler::ToolError>> + Send + 'static>> {
let provider = self.0.clone();
let name = tool_name.to_string();
Box::pin(async move {
let resp = provider.call_tool(&name, arguments).await;
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()),
};
Ok(vec![Content::text(text)])
})
}
fn list_resources(&self) -> Vec<Resource> { vec![] }
fn read_resource(&self, _uri: &str) -> Pin<Box<dyn Future<Output = Result<String, mcp_spec::handler::ResourceError>> + Send + 'static>> {
Box::pin(async { Ok(String::new()) })
}
fn list_prompts(&self) -> Vec<Prompt> { vec![] }
fn get_prompt(&self, _prompt_name: &str) -> Pin<Box<dyn Future<Output = Result<String, mcp_spec::handler::PromptError>> + Send + 'static>> {
Box::pin(async { Ok(String::new()) })
}
}
let router = DocxRouter(provider);
let service = RouterService(router);
let server = Server::new(service);
let transport = mcp_server::ByteTransport::new(stdin(), stdout());
server.run(transport).await?;
}
#[cfg(not(feature = "runtime-server"))]
{
eprintln!("Runtime server disabled. Rebuild with --features runtime-server to run the MCP server.");
}
Ok(())
}
-476
View File
@@ -1,476 +0,0 @@
use anyhow::{Context, Result};
use ::image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
use printpdf::*;
use std::fs::{self, File};
use std::io::{BufWriter, Read};
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
use tracing::{info};
use roxmltree;
use zip::ZipArchive;
use ::lopdf::{dictionary, Object};
pub struct PureRustConverter;
impl PureRustConverter {
pub fn new() -> Self {
Self
}
/// Extract text from DOCX using pure Rust XML parsing
pub fn extract_text_from_docx(&self, docx_path: &Path) -> Result<String> {
let file = File::open(docx_path)?;
let mut archive = ZipArchive::new(file)?;
// Find the main document XML
let mut document_xml = String::new();
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let name = file.name().to_string();
if name == "word/document.xml" {
let mut buf = Vec::new();
file.read_to_end(&mut buf)?;
document_xml = String::from_utf8_lossy(&buf).to_string();
break;
}
}
if document_xml.is_empty() {
anyhow::bail!("No document.xml found in DOCX file");
}
// Parse XML and extract text with basic whitespace semantics
let doc = roxmltree::Document::parse(&document_xml)?;
let mut text = String::new();
let mut last_char: Option<char> = None;
for node in doc.descendants() {
let name = node.tag_name().name();
match name {
// Paragraph boundary
"p" => {
if !text.ends_with('\n') {
text.push('\n');
last_char = Some('\n');
}
}
// Text run
"t" => {
if let Some(node_text) = node.text() {
// Preserve spaces if xml:space="preserve"
let preserve = node.attribute(("xml", "space")).map(|v| v == "preserve").unwrap_or(false);
let mut content = node_text.to_string();
if !preserve {
// Collapse internal newlines and excessive spaces
content = content.replace('\n', " ");
}
if !content.is_empty() {
// Insert a space if needed between words
if let Some(c) = last_char { if !c.is_whitespace() && !content.starts_with([' ', '\n', '\t']) { text.push(' '); } }
text.push_str(&content);
last_char = content.chars().rev().next();
}
}
}
// Line break
"br" => {
text.push('\n');
last_char = Some('\n');
}
// Tab
"tab" => {
text.push('\t');
last_char = Some('\t');
}
_ => {}
}
}
Ok(text.trim().to_string())
}
/// Convert DOCX to PDF using pure Rust (no external dependencies)
pub fn docx_to_pdf_pure(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> {
// Extract text from DOCX
let text = self.extract_text_from_docx(docx_path)
.with_context(|| format!("Failed to extract text from {:?}", docx_path))?;
// Create PDF with extracted text
self.create_pdf_from_text(&text, pdf_path)?;
info!("Successfully converted DOCX to PDF using pure Rust");
Ok(())
}
// Backward-compat wrapper names expected by tests
pub fn convert_docx_to_pdf(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> {
self.docx_to_pdf_pure(docx_path, pdf_path)
}
pub fn convert_docx_to_images(&self, docx_path: &Path, output_dir: &Path) -> Result<Vec<PathBuf>> {
self.docx_to_images_pure(docx_path, output_dir, ImageFormat::Png)
}
pub fn convert_docx_to_images_with_format(&self, docx_path: &Path, output_dir: &Path, format: &str, _dpi: u32) -> Result<Vec<PathBuf>> {
let fmt = match format.to_lowercase().as_str() {
"jpg" | "jpeg" => ImageFormat::Jpeg,
_ => ImageFormat::Png,
};
self.docx_to_images_pure(docx_path, output_dir, fmt)
}
/// Create a PDF from text content
pub fn create_pdf_from_text(&self, text: &str, pdf_path: &Path) -> Result<()> {
let (doc, page1, layer1) = PdfDocument::new("Document", Mm(210.0), Mm(297.0), "Layer 1");
let current_layer = doc.get_page(page1).get_layer(layer1);
// Use embedded font or built-in font
let font = doc.add_builtin_font(BuiltinFont::Helvetica)?;
// Configure text layout
let font_size = 11.0;
let line_height = Mm(5.0);
let margin_left = Mm(20.0);
let margin_top = Mm(280.0);
let margin_bottom = Mm(20.0);
let page_width = Mm(210.0);
let page_height = Mm(297.0);
let text_width = page_width - (margin_left * 2.0);
let lines: Vec<&str> = text.lines().collect();
let mut current_page = page1;
let mut current_layer = layer1;
let mut y_position = margin_top;
for line in lines {
// Check if we need a new page
if y_position < margin_bottom {
let (new_page, new_layer) = doc.add_page(Mm(210.0), Mm(297.0), "Page layer");
current_page = new_page;
current_layer = new_layer;
y_position = margin_top;
}
// Word wrap if line is too long
let words: Vec<&str> = line.split_whitespace().collect();
let mut current_line = String::new();
let max_chars_per_line = 80; // Approximate
for word in words {
if current_line.len() + word.len() + 1 > max_chars_per_line {
// Write current line
if !current_line.is_empty() {
doc.get_page(current_page)
.get_layer(current_layer)
.use_text(&current_line, font_size, margin_left, y_position, &font);
y_position -= line_height;
current_line.clear();
// Check for new page
if y_position < margin_bottom {
let (new_page, new_layer) = doc.add_page(Mm(210.0), Mm(297.0), "Page layer");
current_page = new_page;
current_layer = new_layer;
y_position = margin_top;
}
}
}
if !current_line.is_empty() {
current_line.push(' ');
}
current_line.push_str(word);
}
// Write remaining text in line
if !current_line.is_empty() {
doc.get_page(current_page)
.get_layer(current_layer)
.use_text(&current_line, font_size, margin_left, y_position, &font);
y_position -= line_height;
}
}
// Save PDF
doc.save(&mut BufWriter::new(File::create(pdf_path)?))?;
Ok(())
}
/// Convert PDF to images using pure Rust
pub fn pdf_to_images_pure(
&self,
pdf_path: &Path,
output_dir: &Path,
format: ImageFormat,
) -> Result<Vec<PathBuf>> {
// Parse PDF
let doc = lopdf::Document::load(pdf_path)?;
let pages = doc.get_pages();
fs::create_dir_all(output_dir)?;
let mut output_paths = Vec::new();
// For each page, render to image
for (page_num, (_page_num, _page_id)) in pages.iter().enumerate() {
// Create a blank image for the page
// In a real implementation, you would render the PDF content
let img = self.render_pdf_page_to_image(&doc, page_num)?;
// Save image
let extension = match format {
ImageFormat::Png => "png",
ImageFormat::Jpeg => "jpg",
_ => "png",
};
let output_path = output_dir.join(format!("page_{:03}.{}", page_num + 1, extension));
// JPEG does not support RGBA; convert to RGB if needed
if let ImageFormat::Jpeg = format {
let rgb = img.to_rgb8();
::image::DynamicImage::ImageRgb8(rgb).save_with_format(&output_path, format)?;
} else {
img.save_with_format(&output_path, format)?;
}
output_paths.push(output_path);
}
Ok(output_paths)
}
/// Render a PDF page to image (simplified implementation)
fn render_pdf_page_to_image(&self, _doc: &lopdf::Document, _page_num: usize) -> Result<DynamicImage> {
// This is a simplified implementation
// A full implementation would parse PDF content and render it
// Create a white image as placeholder
let width = 1240; // A4 at 150 DPI
let height = 1754; // A4 at 150 DPI
let mut img = RgbaImage::new(width, height);
// Fill with white background
for pixel in img.pixels_mut() {
*pixel = Rgba([255, 255, 255, 255]);
}
// Add a simple text indicator
// In production, you would properly render PDF content
Ok(DynamicImage::ImageRgba8(img))
}
/// Convert DOCX to images using pure Rust
pub fn docx_to_images_pure(
&self,
docx_path: &Path,
output_dir: &Path,
format: ImageFormat,
) -> Result<Vec<PathBuf>> {
// First convert to PDF
let temp_pdf = NamedTempFile::new()?.into_temp_path();
self.docx_to_pdf_pure(docx_path, &temp_pdf)?;
// Then convert PDF to images
self.pdf_to_images_pure(&temp_pdf, output_dir, format)
}
/// Create a thumbnail from an image
pub fn create_thumbnail(
&self,
image_path: &Path,
output_path: &Path,
width: u32,
height: u32,
) -> Result<()> {
let img = ::image::open(image_path)
.with_context(|| format!("Failed to open image {:?}", image_path))?;
let thumbnail = img.thumbnail(width, height);
thumbnail.save(output_path)
.with_context(|| format!("Failed to save thumbnail to {:?}", output_path))?;
info!("Created thumbnail {}x{} at {:?}", width, height, output_path);
Ok(())
}
/// Merge multiple PDFs using pure Rust
pub fn merge_pdfs_pure(&self, pdf_paths: &[PathBuf], output_path: &Path) -> Result<()> {
use ::lopdf::{Document, Object};
// Create a new document for merging
let mut merged_doc = Document::with_version("1.5");
// Track page tree
let mut all_pages = Vec::new();
for pdf_path in pdf_paths {
let doc = Document::load(pdf_path)?;
// Get pages from the document
let pages = doc.get_pages();
for (_page_num, page_id) in pages.iter() {
// Clone the page object
if let Ok(page_obj) = doc.get_object(*page_id) {
let new_id = merged_doc.new_object_id();
merged_doc.objects.insert(new_id, page_obj.clone());
all_pages.push(new_id);
}
}
}
// Build the page tree for merged document
let pages_id = merged_doc.new_object_id();
let pages_dict = ::lopdf::dictionary! {
"Type" => "Pages",
"Kids" => all_pages.iter().map(|id| Object::Reference(*id)).collect::<Vec<_>>(),
"Count" => all_pages.len() as i32,
};
merged_doc.objects.insert(pages_id, Object::Dictionary(pages_dict));
// Update catalog
let catalog_id = merged_doc.new_object_id();
let catalog = ::lopdf::dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
};
merged_doc.objects.insert(catalog_id, Object::Dictionary(catalog));
merged_doc.trailer.set("Root", Object::Reference(catalog_id));
// Save the merged PDF
merged_doc.save(output_path)?;
info!("Successfully merged {} PDFs into {:?}", pdf_paths.len(), output_path);
Ok(())
}
/// Split a PDF into individual pages using pure Rust
pub fn split_pdf_pure(&self, pdf_path: &Path, output_dir: &Path) -> Result<Vec<PathBuf>> {
use ::lopdf::Document;
fs::create_dir_all(output_dir)?;
let doc = Document::load(pdf_path)?;
let pages = doc.get_pages();
let mut output_paths = Vec::new();
for (i, (_page_num, page_id)) in pages.iter().enumerate() {
// Create a new document with just this page
let mut single_page_doc = Document::with_version("1.5");
// Clone the page
if let Ok(page_obj) = doc.get_object(*page_id) {
let new_page_id = single_page_doc.new_object_id();
single_page_doc.objects.insert(new_page_id, page_obj.clone());
// Create page tree
let pages_id = single_page_doc.new_object_id();
let pages_dict = ::lopdf::dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(new_page_id)],
"Count" => 1,
};
single_page_doc.objects.insert(pages_id, Object::Dictionary(pages_dict));
// Create catalog
let catalog_id = single_page_doc.new_object_id();
let catalog = ::lopdf::dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
};
single_page_doc.objects.insert(catalog_id, Object::Dictionary(catalog));
single_page_doc.trailer.set("Root", Object::Reference(catalog_id));
// Save the page
let output_path = output_dir.join(format!("page_{:03}.pdf", i + 1));
single_page_doc.save(&output_path)?;
output_paths.push(output_path);
}
}
info!("Split PDF into {} pages", output_paths.len());
Ok(output_paths)
}
/// Parse and render markdown to PDF
pub fn markdown_to_pdf(&self, markdown: &str, pdf_path: &Path) -> Result<()> {
use pulldown_cmark::{Parser, Event, Tag, TagEnd};
let parser = Parser::new(markdown);
let mut plain_text = String::new();
let mut in_code_block = false;
let mut list_depth = 0;
for event in parser {
match event {
Event::Text(text) => {
if in_code_block {
plain_text.push_str(" ");
} else if list_depth > 0 {
plain_text.push_str(&" ".repeat(list_depth));
}
plain_text.push_str(&text);
}
Event::Start(tag) => {
match tag {
Tag::Heading { level, .. } => {
plain_text.push('\n');
plain_text.push_str(&"#".repeat(level as usize));
plain_text.push(' ');
}
Tag::Paragraph => {
if !plain_text.is_empty() {
plain_text.push_str("\n\n");
}
}
Tag::List(_) => {
list_depth += 1;
plain_text.push('\n');
}
Tag::Item => {
plain_text.push_str("");
}
Tag::CodeBlock(_) => {
in_code_block = true;
plain_text.push_str("\n\n");
}
Tag::Emphasis => plain_text.push('*'),
Tag::Strong => plain_text.push_str("**"),
_ => {}
}
}
Event::End(tag) => {
match tag {
TagEnd::Heading(_) => plain_text.push_str("\n\n"),
TagEnd::Paragraph => plain_text.push('\n'),
TagEnd::List(_) => {
list_depth = list_depth.saturating_sub(1);
plain_text.push('\n');
}
TagEnd::Item => plain_text.push('\n'),
TagEnd::CodeBlock => {
in_code_block = false;
plain_text.push_str("\n\n");
}
TagEnd::Emphasis => plain_text.push('*'),
TagEnd::Strong => plain_text.push_str("**"),
_ => {}
}
}
Event::Code(code) => {
plain_text.push('`');
plain_text.push_str(&code);
plain_text.push('`');
}
Event::SoftBreak => plain_text.push(' '),
Event::HardBreak => plain_text.push('\n'),
_ => {}
}
}
self.create_pdf_from_text(&plain_text, pdf_path)?;
Ok(())
}
}
-42
View File
@@ -1,42 +0,0 @@
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolOutcome {
Ok { message: Option<String> },
Created { document_id: String, message: Option<String> },
Text { text: String },
Metadata { metadata: serde_json::Value },
Documents { documents: serde_json::Value },
Images { images: Vec<String>, message: Option<String> },
Security { security: serde_json::Value },
Storage { storage: serde_json::Value },
Statistics { statistics: serde_json::Value },
Structure { structure: serde_json::Value },
Error { code: ErrorCode, error: String, hint: Option<String> },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ErrorCode {
DocNotFound,
ValidationError,
SecurityDenied,
LimitExceeded,
UnknownTool,
InternalError,
}
impl ToolOutcome {
pub fn success(&self) -> bool {
!matches!(self, ToolOutcome::Error { .. })
}
pub fn into_json(self) -> serde_json::Value {
serde_json::to_value(self).unwrap_or_else(|e| serde_json::json!({
"type": "error",
"code": ErrorCode::InternalError,
"error": format!("serialization failed: {}", e),
}))
}
}
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Set
@dataclass
class SecurityConfig:
readonly_mode: bool
sandbox_mode: bool
allow_external_tools: bool
allow_network: bool
max_document_size: int
max_open_documents: int
def get_summary(self) -> str:
parts = []
if self.readonly_mode:
parts.append("readonly")
if self.sandbox_mode:
parts.append("sandbox")
if self.allow_external_tools:
parts.append("external-tools")
if self.allow_network:
parts.append("network")
return ", ".join(parts) or "default"
# Tools allowed in readonly mode
READONLY_COMMANDS: Set[str] = {
"list_documents",
"open_document",
"extract_text",
"get_metadata",
"get_document_structure",
"get_outline",
"get_ranges",
"get_tables",
"list_images",
"list_hyperlinks",
"get_fields_summary",
"get_document_properties",
"get_word_count",
"search_text",
"analyze_formatting",
"get_security_info",
"get_storage_info",
"list_templates",
}
# Tools that modify documents
WRITE_COMMANDS: Set[str] = {
"create_document",
"add_paragraph",
"add_heading",
"add_table",
"add_section_break",
"add_list",
"add_list_item",
"add_page_break",
"insert_toc",
"insert_bookmark_after_heading",
"set_header",
"set_footer",
"set_page_numbering",
"embed_page_number_fields",
"add_image",
"add_hyperlink",
"find_and_replace",
"find_and_replace_advanced",
"apply_paragraph_format",
"save_document",
"close_document",
"convert_to_pdf",
"export_pdf_with_field_refresh",
"convert_to_images",
"convert_to_images_with_preference",
"merge_documents",
"split_document",
"replace_range_text",
"set_table_cell_text",
"set_document_properties",
"insert_after_heading",
"sanitize_external_links",
"redact_text",
"strip_personal_info",
"export_to_markdown",
"export_to_html",
"open_template",
"generate_from_template",
}
def is_command_allowed(name: str, config: SecurityConfig) -> bool:
if config.readonly_mode:
return name in READONLY_COMMANDS
return True
-557
View File
@@ -1,557 +0,0 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::env;
use tracing::{debug, info};
use clap::{Parser, Subcommand};
/// Command line arguments for the DOCX MCP server
#[derive(Parser, Debug)]
#[command(name = "docx-mcp")]
#[command(about = "A comprehensive Model Context Protocol (MCP) server for Microsoft Word DOCX file manipulation")]
#[command(version)]
pub struct Args {
/// Enable readonly mode - only allow viewing operations
#[arg(long, env = "DOCX_MCP_READONLY")]
pub readonly: bool,
/// Comma-separated whitelist of allowed commands
#[arg(long, env = "DOCX_MCP_WHITELIST", value_delimiter = ',')]
pub whitelist: Option<Vec<String>>,
/// Comma-separated blacklist of forbidden commands
#[arg(long, env = "DOCX_MCP_BLACKLIST", value_delimiter = ',')]
pub blacklist: Option<Vec<String>>,
/// Enable sandbox mode - restrict file operations to temp directory only
#[arg(long, env = "DOCX_MCP_SANDBOX")]
pub sandbox: bool,
/// Disable external tools (LibreOffice, etc.)
#[arg(long, env = "DOCX_MCP_NO_EXTERNAL_TOOLS")]
pub no_external_tools: bool,
/// Disable network operations
#[arg(long, env = "DOCX_MCP_NO_NETWORK")]
pub no_network: bool,
/// Maximum document size in bytes
#[arg(long, env = "DOCX_MCP_MAX_SIZE")]
pub max_size: Option<usize>,
/// Maximum number of open documents
#[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>,
}
/// Security configuration for the MCP server
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityConfig {
/// If true, only allow read-only operations
pub readonly_mode: bool,
/// Whitelist of allowed commands (if set, only these commands are allowed)
pub command_whitelist: Option<HashSet<String>>,
/// Blacklist of forbidden commands (if set, these commands are blocked)
pub command_blacklist: Option<HashSet<String>>,
/// Maximum document size in bytes (default: 100MB)
pub max_document_size: usize,
/// Maximum number of open documents (default: 50)
pub max_open_documents: usize,
/// Allow external tool usage (LibreOffice, etc.)
pub allow_external_tools: bool,
/// Allow network operations (downloading templates, fonts, etc.)
pub allow_network: bool,
/// Sandbox mode - restricts file operations to temp directory only
pub sandbox_mode: bool,
}
/// Top-level CLI subcommands
#[derive(Subcommand, Debug, Clone, Serialize, Deserialize)]
pub enum CliCommand {
/// Font utilities
Fonts {
#[command(subcommand)]
action: FontsAction,
},
}
/// Font-related actions
#[derive(Subcommand, Debug, Clone, Serialize, Deserialize)]
pub enum FontsAction {
/// Download open-source fonts into assets/fonts
Download,
/// Verify checksums of fonts in assets/fonts
Verify,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
readonly_mode: false,
command_whitelist: None,
command_blacklist: None,
max_document_size: 100 * 1024 * 1024, // 100MB
max_open_documents: 50,
allow_external_tools: true,
allow_network: true,
sandbox_mode: false,
}
}
}
impl SecurityConfig {
/// Create configuration from command line arguments
pub fn from_args(args: Args) -> Self {
let mut config = Self::default();
// Apply command line arguments
if args.readonly {
config.readonly_mode = true;
info!("Running in READONLY mode - only viewing operations allowed");
}
if let Some(whitelist) = args.whitelist {
let commands: HashSet<String> = whitelist.into_iter().collect();
info!("Command whitelist enabled with {} commands", commands.len());
config.command_whitelist = Some(commands);
}
if let Some(blacklist) = args.blacklist {
let commands: HashSet<String> = blacklist.into_iter().collect();
info!("Command blacklist enabled with {} blocked commands", commands.len());
config.command_blacklist = Some(commands);
}
if args.sandbox {
config.sandbox_mode = true;
config.allow_external_tools = false;
config.allow_network = false;
info!("Running in SANDBOX mode - restricted file operations");
}
if args.no_external_tools {
config.allow_external_tools = false;
info!("External tools disabled");
}
if args.no_network {
config.allow_network = false;
info!("Network operations disabled");
}
if let Some(size) = args.max_size {
config.max_document_size = size;
info!("Max document size set to {} bytes", size);
}
if let Some(max) = args.max_docs {
config.max_open_documents = max;
info!("Max open documents set to {}", max);
}
config
}
/// Load configuration from environment variables (deprecated, use from_args instead)
pub fn from_env() -> Self {
let mut config = Self::default();
// Check for readonly mode
if env::var("DOCX_MCP_READONLY").unwrap_or_default() == "true" {
config.readonly_mode = true;
info!("Running in READONLY mode - only viewing operations allowed");
}
// Check for command whitelist
if let Ok(whitelist) = env::var("DOCX_MCP_WHITELIST") {
let commands: HashSet<String> = whitelist
.split(',')
.map(|s| s.trim().to_string())
.collect();
config.command_whitelist = Some(commands.clone());
info!("Command whitelist enabled with {} commands", commands.len());
}
// Check for command blacklist
if let Ok(blacklist) = env::var("DOCX_MCP_BLACKLIST") {
let commands: HashSet<String> = blacklist
.split(',')
.map(|s| s.trim().to_string())
.collect();
config.command_blacklist = Some(commands.clone());
info!("Command blacklist enabled with {} blocked commands", commands.len());
}
// Check for sandbox mode
if env::var("DOCX_MCP_SANDBOX").unwrap_or_default() == "true" {
config.sandbox_mode = true;
config.allow_external_tools = false;
config.allow_network = false;
info!("Running in SANDBOX mode - restricted file operations");
}
// Check for external tools permission
if env::var("DOCX_MCP_NO_EXTERNAL_TOOLS").unwrap_or_default() == "true" {
config.allow_external_tools = false;
info!("External tools disabled");
}
// Check for network permission
if env::var("DOCX_MCP_NO_NETWORK").unwrap_or_default() == "true" {
config.allow_network = false;
info!("Network operations disabled");
}
// Max document size
if let Ok(size) = env::var("DOCX_MCP_MAX_SIZE") {
if let Ok(bytes) = size.parse::<usize>() {
config.max_document_size = bytes;
info!("Max document size set to {} bytes", bytes);
}
}
// Max open documents
if let Ok(max) = env::var("DOCX_MCP_MAX_DOCS") {
if let Ok(count) = max.parse::<usize>() {
config.max_open_documents = count;
info!("Max open documents set to {}", count);
}
}
config
}
/// Check if a command is allowed based on security configuration
pub fn is_command_allowed(&self, command: &str) -> bool {
// First check if it's a readonly command
let readonly_commands = Self::get_readonly_commands();
let is_readonly_command = readonly_commands.contains(command);
// In readonly mode, only allow readonly commands
if self.readonly_mode && !is_readonly_command {
debug!("Command '{}' blocked: readonly mode", command);
return false;
}
// Check whitelist (if set, only whitelisted commands are allowed);
// Whitelist takes precedence over blacklist.
if let Some(ref whitelist) = self.command_whitelist {
if whitelist.contains(command) {
return true;
} else {
debug!("Command '{}' blocked: not in whitelist", command);
return false;
}
}
// If no whitelist, enforce blacklist if present
if let Some(ref blacklist) = self.command_blacklist {
if blacklist.contains(command) {
debug!("Command '{}' blocked: in blacklist", command);
return false;
}
}
// Additional checks for specific command categories
if command.starts_with("convert_") && !self.allow_external_tools {
debug!("Command '{}' blocked: external tools disabled", command);
return false;
}
true
}
/// Get list of readonly commands
pub fn get_readonly_commands() -> HashSet<&'static str> {
let mut commands = HashSet::new();
// Document viewing commands
commands.insert("open_document");
commands.insert("extract_text");
commands.insert("get_metadata");
commands.insert("list_documents");
commands.insert("get_document_info");
commands.insert("read_paragraph");
commands.insert("read_table");
commands.insert("read_section");
commands.insert("search_text");
commands.insert("get_document_structure");
commands.insert("get_styles");
commands.insert("get_headers_footers");
commands.insert("get_page_count");
commands.insert("get_word_count");
commands.insert("get_table_of_contents");
commands.insert("list_bookmarks");
commands.insert("list_hyperlinks");
commands.insert("list_comments");
commands.insert("list_footnotes");
commands.insert("list_endnotes");
commands.insert("get_document_properties");
// Analysis commands
commands.insert("analyze_formatting");
commands.insert("check_spelling");
commands.insert("check_grammar");
commands.insert("get_statistics");
commands.insert("compare_documents");
// Export commands (readonly as they don't modify the original)
commands.insert("export_to_json");
commands.insert("export_to_markdown");
commands.insert("export_to_html");
commands.insert("create_preview");
commands.insert("get_security_info");
commands.insert("get_storage_info");
commands
}
/// Get list of write commands (for documentation)
pub fn get_write_commands() -> HashSet<&'static str> {
let mut commands = HashSet::new();
// Document creation/modification
commands.insert("create_document");
commands.insert("save_document");
commands.insert("close_document");
// Content addition
commands.insert("add_paragraph");
commands.insert("add_heading");
commands.insert("add_table");
commands.insert("add_list");
commands.insert("add_page_break");
commands.insert("add_section_break");
commands.insert("add_image");
commands.insert("add_chart");
commands.insert("add_shape");
commands.insert("add_hyperlink");
commands.insert("add_bookmark");
commands.insert("add_footnote");
commands.insert("add_endnote");
commands.insert("add_comment");
commands.insert("add_watermark");
// Content modification
commands.insert("edit_paragraph");
commands.insert("delete_paragraph");
commands.insert("find_and_replace");
commands.insert("update_table");
commands.insert("update_style");
commands.insert("set_header");
commands.insert("set_footer");
commands.insert("set_margins");
commands.insert("set_page_size");
commands.insert("apply_template");
commands.insert("apply_style");
commands.insert("apply_theme");
// Document operations
commands.insert("merge_documents");
commands.insert("split_document");
commands.insert("convert_to_pdf");
commands.insert("convert_to_images");
commands.insert("protect_document");
commands.insert("unprotect_document");
commands.insert("track_changes");
commands.insert("accept_changes");
commands.insert("reject_changes");
commands
}
/// Check if a file path is allowed based on sandbox configuration
pub fn is_path_allowed(&self, path: &std::path::Path) -> bool {
if !self.sandbox_mode {
return true;
}
// In sandbox mode, only allow operations in temp directory
let temp_dir = std::env::temp_dir();
// Fast-path for non-existent paths under common temp prefixes
if !path.exists() {
if let Some(s) = path.to_str() {
if s.starts_with("/tmp/") || s.starts_with("/private/tmp/") {
return true;
}
}
}
// Avoid requiring the file to exist. Use parent directory for canonicalization when needed.
let candidate = if path.exists() { path.to_path_buf() } else { path.parent().unwrap_or(path).to_path_buf() };
if let Ok(canonical_path) = candidate.canonicalize() {
if let Ok(canonical_temp) = temp_dir.canonicalize() {
if canonical_path.starts_with(&canonical_temp) {
return true;
}
// macOS sometimes resolves to /private/var; normalize for comparison
let cp = canonical_path.to_string_lossy();
let ct = canonical_temp.to_string_lossy();
let cp_norm = cp.replace("/private", "");
let ct_norm = ct.replace("/private", "");
if cp_norm.starts_with(&ct_norm) {
return true;
}
// Heuristic for macOS TMP subfolders (…/T/…)
if cp_norm.contains("/T/") {
return true;
}
// Heuristic for Linux /tmp
if cp_norm.starts_with("/tmp/") {
return true;
}
}
}
false
}
/// Get a summary of current security settings
pub fn get_summary(&self) -> String {
let mut summary: Vec<String> = Vec::new();
if self.readonly_mode {
summary.push("📖 READONLY MODE".to_string());
}
if self.sandbox_mode {
summary.push("🔒 SANDBOX MODE".to_string());
}
if let Some(ref whitelist) = self.command_whitelist {
summary.push(format!("✅ Whitelist: {} commands", whitelist.len()));
}
if let Some(ref blacklist) = self.command_blacklist {
summary.push(format!("🚫 Blacklist: {} commands", blacklist.len()));
}
if !self.allow_external_tools {
summary.push("🔧 No external tools".to_string());
}
if !self.allow_network {
summary.push("🌐 No network access".to_string());
}
if summary.is_empty() {
"Standard mode (all features enabled)".to_string()
} else {
summary.join(" | ")
}
}
}
/// Security middleware to check commands before execution
pub struct SecurityMiddleware {
config: SecurityConfig,
}
impl SecurityMiddleware {
pub fn new(config: SecurityConfig) -> Self {
Self { config }
}
/// Check if a command should be allowed to execute
pub fn check_command(&self, command: &str, arguments: &serde_json::Value) -> Result<(), SecurityError> {
// Check if command is allowed
if !self.config.is_command_allowed(command) {
return Err(SecurityError::CommandNotAllowed(command.to_string()));
}
// Check file paths in arguments if in sandbox mode
if self.config.sandbox_mode {
self.check_paths_in_arguments(arguments)?;
}
// Check document size limits for open/create operations
if command == "open_document" {
if let Some(path) = arguments.get("path").and_then(|v| v.as_str()) {
self.check_file_size(path)?;
}
}
Ok(())
}
fn check_paths_in_arguments(&self, arguments: &serde_json::Value) -> Result<(), SecurityError> {
// Recursively check all string values that look like paths
match arguments {
serde_json::Value::String(s) => {
if s.contains('/') || s.contains('\\') {
let path = std::path::Path::new(s);
if !self.config.is_path_allowed(path) {
return Err(SecurityError::PathNotAllowed(s.to_string()));
}
}
}
serde_json::Value::Object(map) => {
for value in map.values() {
self.check_paths_in_arguments(value)?;
}
}
serde_json::Value::Array(arr) => {
for value in arr {
self.check_paths_in_arguments(value)?;
}
}
_ => {}
}
Ok(())
}
fn check_file_size(&self, path: &str) -> Result<(), SecurityError> {
let file_path = std::path::Path::new(path);
if let Ok(metadata) = std::fs::metadata(file_path) {
if metadata.len() as usize > self.config.max_document_size {
return Err(SecurityError::FileTooLarge {
size: metadata.len() as usize,
max_size: self.config.max_document_size,
});
}
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum SecurityError {
#[error("Command not allowed: {0}")]
CommandNotAllowed(String),
#[error("Path not allowed in sandbox mode: {0}")]
PathNotAllowed(String),
#[error("File too large: {size} bytes (max: {max_size} bytes)")]
FileTooLarge { size: usize, max_size: usize },
#[error("Maximum number of open documents exceeded")]
TooManyDocuments,
#[error("Operation requires external tools which are disabled")]
ExternalToolsDisabled,
#[error("Operation requires network access which is disabled")]
NetworkDisabled,
}
+916
View File
@@ -0,0 +1,916 @@
from __future__ import annotations
import json
import logging
import os
from typing import Any, Dict, Optional
from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
from docx_tools import DocxToolsProvider
from security import SecurityConfig, is_command_allowed
from templates import list_templates
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("py-docx-mcp")
TEMPLATES_DIR = os.getenv("DOCX_MCP_TEMPLATES_DIR", "/templates")
def build_tools_list(provider: DocxToolsProvider, security_config: SecurityConfig) -> Dict[str, Any]:
"""
Build the tools list returned by tools/list,
matching the shape expected by OpenWebUI.
"""
def allowed(name: str) -> bool:
return is_command_allowed(name, security_config)
tools = []
def add(name: str, desc: str, required: list[str],
extra_props: Optional[Dict[str, Any]] = None):
if not allowed(name):
return
props = dict(extra_props or {})
for r in required:
if r not in props:
props[r] = {"type": "string"}
tools.append({
"name": name,
"description": desc,
"inputSchema": {
"type": "object",
"properties": props,
"required": required,
},
})
add("create_document",
"Create a new empty DOCX document",
[])
add("open_document",
"Open an existing DOCX document",
["path"],
{"path": {"type": "string", "description": "Path to the DOCX file"}})
add("add_paragraph",
"Add a paragraph with optional styling to the document",
["document_id", "text"],
{
"document_id": {"type": "string"},
"text": {"type": "string"},
"style": {
"type": "object",
"description": "Paragraph style options (font_family, font_size, bold, italic, underline, color, alignment)"
},
"return_content": {"type": "boolean", "description": "Return document content as base64", "default": False},
})
add("add_heading",
"Add a heading to the document",
["document_id", "text", "level"],
{
"document_id": {"type": "string"},
"text": {"type": "string"},
"level": {"type": "integer", "description": "Heading level (1-6)"},
"return_content": {"type": "boolean", "default": False},
})
add("add_table",
"Add a table to the document",
["document_id", "rows"],
{
"document_id": {"type": "string"},
"rows": {"type": "array", "items": {"type": "array", "items": {"type": "string"}}},
"headers": {"type": "array", "items": {"type": "string"}},
"border_style": {"type": "string"},
"col_widths": {"type": "array", "items": {"type": "integer"}},
"cell_shading": {"type": "string"},
"merges": {"type": "array", "items": {"type": "object"}},
"return_content": {"type": "boolean", "default": False},
})
add("add_section_break",
"Insert a section break with optional page setup",
["document_id"],
{
"document_id": {"type": "string"},
"page_size": {"type": "string"},
"orientation": {"type": "string"},
"margins": {"type": "object"},
"return_content": {"type": "boolean", "default": False},
})
add("add_list",
"Add a bulleted or numbered list to the document",
["document_id", "items"],
{
"document_id": {"type": "string"},
"items": {"type": "array", "items": {"type": "string"}},
"ordered": {"type": "boolean", "default": False},
"return_content": {"type": "boolean", "default": False},
})
add("add_list_item",
"Add a single list item with a specific level",
["document_id", "text"],
{
"document_id": {"type": "string"},
"text": {"type": "string"},
"level": {"type": "integer", "default": 0},
"ordered": {"type": "boolean", "default": False},
"return_content": {"type": "boolean", "default": False},
})
add("add_page_break",
"Add a page break to the document",
["document_id"],
{"return_content": {"type": "boolean", "default": False}})
add("insert_toc",
"Insert a Table of Contents placeholder",
["document_id"],
{
"from_level": {"type": "integer", "default": 1},
"to_level": {"type": "integer", "default": 3},
"right_align_dots": {"type": "boolean", "default": True},
"return_content": {"type": "boolean", "default": False},
})
add("insert_bookmark_after_heading",
"Insert a bookmark immediately after the first matching heading",
["document_id", "heading_text", "name"],
{"return_content": {"type": "boolean", "default": False}})
add("set_header",
"Set the document header",
["document_id", "text"],
{"return_content": {"type": "boolean", "default": False}})
add("set_footer",
"Set the document footer",
["document_id", "text"],
{"return_content": {"type": "boolean", "default": False}})
add("set_page_numbering",
"Set a simple page numbering text in header or footer",
["document_id"],
{
"location": {"type": "string", "default": "footer"},
"template": {"type": "string"},
"return_content": {"type": "boolean", "default": False},
})
add("embed_page_number_fields",
"Replace placeholder 'Page {PAGE} of {PAGES}' with Word field codes (best-effort)",
["document_id"],
{"return_content": {"type": "boolean", "default": False}})
add("add_image",
"Insert an image into the document",
["document_id", "data_base64"],
{
"data_base64": {"type": "string"},
"width": {"type": "integer"},
"height": {"type": "integer"},
"alt_text": {"type": "string"},
"return_content": {"type": "boolean", "default": False},
})
add("add_hyperlink",
"Insert a hyperlink into the document",
["document_id", "text", "url"],
{"return_content": {"type": "boolean", "default": False}})
add("find_and_replace",
"Find and replace text in the document",
["document_id", "find_text", "replace_text"],
{"return_content": {"type": "boolean", "default": False}})
add("find_and_replace_advanced",
"Find/replace with regex, case, whole-word, preserving runs",
["document_id", "pattern", "replacement"],
{
"case_sensitive": {"type": "boolean", "default": False},
"whole_word": {"type": "boolean", "default": False},
"use_regex": {"type": "boolean", "default": False},
"return_content": {"type": "boolean", "default": False},
})
add("apply_paragraph_format",
"Apply paragraph formatting to paragraphs matching a simple selector",
["document_id"],
{
"contains": {"type": "string"},
"format": {"type": "object"},
"return_content": {"type": "boolean", "default": False},
})
add("extract_text",
"Extract all text content from the document",
["document_id"])
add("get_tables",
"List tables with dimensions, merges, and cell content",
["document_id"])
add("list_images",
"List images with width/height and alt text",
["document_id"])
add("list_hyperlinks",
"List hyperlinks in the document",
["document_id"])
add("get_fields_summary",
"Summarize Word fields (PAGE, NUMPAGES, TOC) in document and headers/footers",
["document_id"])
add("strip_personal_info",
"Remove personal info from metadata and core.xml (best-effort)",
["document_id"])
add("get_metadata",
"Get document metadata",
["document_id"])
add("save_document",
"Save the document to a specific path and return its content",
["document_id", "output_path"],
{"return_content": {"type": "boolean", "default": True}})
add("close_document",
"Close the document and free resources",
["document_id"])
add("list_documents",
"List all open documents",
[])
add("convert_to_pdf",
"Convert a DOCX document to PDF and return the file",
["document_id", "output_path"],
{
"prefer_external": {"type": "boolean", "default": False},
"return_content": {"type": "boolean", "default": True},
})
add("export_pdf_with_field_refresh",
"Embed page fields then export to PDF (hi-fidelity when available)",
["document_id", "output_path"],
{
"prefer_external": {"type": "boolean", "default": True},
"return_content": {"type": "boolean", "default": True},
})
add("convert_to_images",
"Convert a DOCX document to images (one per page) and return them",
["document_id", "output_dir"],
{
"format": {"type": "string", "default": "png"},
"dpi": {"type": "integer", "default": 150},
"return_content": {"type": "boolean", "default": True},
})
add("convert_to_images_with_preference",
"Convert DOCX to images, preferring external hi-fidelity path",
["document_id", "output_dir"],
{
"format": {"type": "string", "default": "png"},
"dpi": {"type": "integer", "default": 150},
"prefer_external": {"type": "boolean", "default": True},
"return_content": {"type": "boolean", "default": True},
})
add("merge_documents",
"Merge multiple DOCX documents into one and return the result",
["document_ids", "output_path"],
{
"document_ids": {"type": "array", "items": {"type": "string"}},
"return_content": {"type": "boolean", "default": True},
})
add("split_document",
"Split a document at page breaks and return parts",
["document_id", "output_dir"],
{"return_content": {"type": "boolean", "default": True}})
add("get_document_structure",
"Get the structural overview of the document (headings, sections, etc.)",
["document_id"])
add("get_outline",
"Return heading outline with range_ids",
["document_id"])
add("get_ranges",
"Resolve a selector to range_ids",
["document_id", "selector"])
add("replace_range_text",
"Replace text in a paragraph/heading by range_id",
["document_id", "range_id", "text"],
{"return_content": {"type": "boolean", "default": False}})
add("set_table_cell_text",
"Set text in a table cell by indices",
["document_id", "table_index", "row", "col", "text"],
{
"table_index": {"type": "integer"},
"row": {"type": "integer"},
"col": {"type": "integer"},
"return_content": {"type": "boolean", "default": False},
})
add("get_document_properties",
"Get document properties (title, subject, author, timestamps)",
["document_id"])
add("set_document_properties",
"Set document properties (title, subject, author)",
["document_id"],
{
"title": {"type": "string"},
"subject": {"type": "string"},
"author": {"type": "string"},
"return_content": {"type": "boolean", "default": False},
})
add("insert_after_heading",
"Insert a paragraph after the first heading that matches text",
["document_id", "heading_text", "text"],
{"return_content": {"type": "boolean", "default": False}})
add("sanitize_external_links",
"Remove external hyperlinks (http/https)",
["document_id"])
add("redact_text",
"Redact text using regex/whole-word with █ character",
["document_id", "pattern"],
{
"use_regex": {"type": "boolean", "default": False},
"whole_word": {"type": "boolean", "default": False},
"case_sensitive": {"type": "boolean", "default": False},
"return_content": {"type": "boolean", "default": False},
})
add("analyze_formatting",
"Analyze the formatting used throughout the document",
["document_id"])
add("get_word_count",
"Get detailed word count statistics for the document",
["document_id"])
add("search_text",
"Search for text patterns in the document",
["document_id", "search_term"],
{
"case_sensitive": {"type": "boolean", "default": False},
"whole_word": {"type": "boolean", "default": False},
})
add("export_to_markdown",
"Export document content to Markdown format and return the file",
["document_id", "output_path"],
{"return_content": {"type": "boolean", "default": True}})
add("export_to_html",
"Export document content to HTML format and return the file",
["document_id", "output_path"],
{"return_content": {"type": "boolean", "default": True}})
add("get_security_info",
"Get information about current security settings and restrictions",
[])
add("get_storage_info",
"Get information about temporary storage usage",
[])
add("list_templates",
"List available document templates from the templates directory",
[])
add("open_template",
"Open a template document by name from the templates directory",
["name"])
add("generate_from_template",
"Generate a new document from a template and return the file",
["template_name", "output_path", "fields"],
{
"fields": {"type": "object"},
"return_content": {"type": "boolean", "default": True},
})
return {"tools": tools}
def make_app() -> FastAPI:
app = FastAPI(title="py-docx-mcp")
readonly_mode = os.getenv("DOCX_MCP_READONLY", "false").lower() in ("true", "1")
sandbox_mode = os.getenv("DOCX_MCP_SANDBOX", "true").lower() in ("true", "1")
allow_external_tools = os.getenv("DOCX_MCP_ALLOW_EXTERNAL_TOOLS", "false").lower() in ("true", "1")
allow_network = os.getenv("DOCX_MCP_ALLOW_NETWORK", "false").lower() in ("true", "1")
max_document_size = int(os.getenv("DOCX_MCP_MAX_SIZE", "104857600"))
max_open_documents = int(os.getenv("DOCX_MCP_MAX_DOCS", "30"))
api_key = os.getenv("DOCX_MCP_API_KEY", "").strip()
security_config = SecurityConfig(
readonly_mode=readonly_mode,
sandbox_mode=sandbox_mode,
allow_external_tools=allow_external_tools,
allow_network=allow_network,
max_document_size=max_document_size,
max_open_documents=max_open_documents,
)
provider = DocxToolsProvider(
security_config=security_config,
templates_dir=TEMPLATES_DIR,
)
tools_list = build_tools_list(provider, security_config)
def get_bearer_token(request: Request) -> Optional[str]:
auth = (request.headers.get("Authorization") or "").strip()
if auth.startswith("Bearer "):
return auth[len("Bearer "):].strip()
return None
def require_auth(request: Request) -> bool:
if not api_key:
return True
token = get_bearer_token(request)
if not token or token != api_key:
return False
return True
@app.get("/")
async def root(request: Request):
if not require_auth(request):
return JSONResponse(status_code=401, content={"error": "Missing or invalid API key"})
return {
"service": "py-docx-mcp",
"transport": "streamable-http",
"docs": "Use POST / with MCP JSON-RPC (initialize, tools/list, tools/call).",
}
@app.post("/")
async def mcp_endpoint(request: Request):
if not require_auth(request):
return JSONResponse(status_code=401, content={"error": "Missing or invalid API key"})
body = await request.json()
method = body.get("method")
params = body.get("params") or {}
req_id = body.get("id")
# MCP: initialize
if method == "initialize":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "py-docx-mcp",
"version": "0.1.0",
},
},
}
# MCP: tools/list
if method == "tools/list":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": tools_list,
}
# MCP: tools/call
if method == "tools/call":
tool_name = params.get("name")
tool_args = params.get("arguments") or {}
# Security check: only allowed commands
if not is_command_allowed(tool_name, security_config):
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {
"code": -32000,
"message": f"Command '{tool_name}' not allowed by security policy",
},
}
try:
result = call_tool_impl(tool_name, tool_args, provider)
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"content": [
{
"type": "text",
"text": (result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)),
}
]
},
}
except Exception as e:
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {
"code": -32000,
"message": str(e),
},
}
# Unknown method
return JSONResponse(
status_code=400,
content={"error": "Unknown method: " + str(method)},
)
return app
def call_tool_impl(name: str, args: Dict[str, Any], provider: DocxToolsProvider) -> Any:
# Delegate to provider methods, matching names.
# This central dispatcher keeps tool signatures in one place.
if name == "create_document":
return provider.create_document()
if name == "open_document":
return provider.open_document(args["path"])
if name == "add_paragraph":
return provider.add_paragraph(
args["document_id"],
args["text"],
args.get("style") or {},
return_content=args.get("return_content", False),
)
if name == "add_heading":
return provider.add_heading(
args["document_id"],
args["text"],
int(args["level"]),
return_content=args.get("return_content", False),
)
if name == "add_table":
return provider.add_table(
args["document_id"],
args["rows"],
headers=args.get("headers"),
border_style=args.get("border_style"),
col_widths=args.get("col_widths"),
cell_shading=args.get("cell_shading"),
merges=args.get("merges"),
return_content=args.get("return_content", False),
)
if name == "add_section_break":
return provider.add_section_break(
args["document_id"],
page_size=args.get("page_size"),
orientation=args.get("orientation"),
margins=args.get("margins"),
return_content=args.get("return_content", False),
)
if name == "add_list":
return provider.add_list(
args["document_id"],
args["items"],
ordered=bool(args.get("ordered", False)),
return_content=args.get("return_content", False),
)
if name == "add_list_item":
return provider.add_list_item(
args["document_id"],
args["text"],
level=int(args.get("level", 0)),
ordered=bool(args.get("ordered", False)),
return_content=args.get("return_content", False),
)
if name == "add_page_break":
return provider.add_page_break(
args["document_id"],
return_content=args.get("return_content", False),
)
if name == "insert_toc":
return provider.insert_toc(
args["document_id"],
from_level=int(args.get("from_level", 1)),
to_level=int(args.get("to_level", 3)),
right_align_dots=bool(args.get("right_align_dots", True)),
return_content=args.get("return_content", False),
)
if name == "insert_bookmark_after_heading":
return provider.insert_bookmark_after_heading(
args["document_id"],
args["heading_text"],
args["name"],
return_content=args.get("return_content", False),
)
if name == "set_header":
return provider.set_header(
args["document_id"],
args["text"],
return_content=args.get("return_content", False),
)
if name == "set_footer":
return provider.set_footer(
args["document_id"],
args["text"],
return_content=args.get("return_content", False),
)
if name == "set_page_numbering":
return provider.set_page_numbering(
args["document_id"],
location=args.get("location", "footer"),
template=args.get("template"),
return_content=args.get("return_content", False),
)
if name == "embed_page_number_fields":
return provider.embed_page_number_fields(
args["document_id"],
return_content=args.get("return_content", False),
)
if name == "add_image":
return provider.add_image(
args["document_id"],
args["data_base64"],
width=args.get("width"),
height=args.get("height"),
alt_text=args.get("alt_text"),
return_content=args.get("return_content", False),
)
if name == "add_hyperlink":
return provider.add_hyperlink(
args["document_id"],
args["text"],
args["url"],
return_content=args.get("return_content", False),
)
if name == "find_and_replace":
return provider.find_and_replace(
args["document_id"],
args["find_text"],
args["replace_text"],
return_content=args.get("return_content", False),
)
if name == "find_and_replace_advanced":
return provider.find_and_replace_advanced(
args["document_id"],
args["pattern"],
args["replacement"],
case_sensitive=bool(args.get("case_sensitive", False)),
whole_word=bool(args.get("whole_word", False)),
use_regex=bool(args.get("use_regex", False)),
return_content=args.get("return_content", False),
)
if name == "apply_paragraph_format":
return provider.apply_paragraph_format(
args["document_id"],
contains=args.get("contains"),
format=args.get("format") or {},
return_content=args.get("return_content", False),
)
if name == "extract_text":
return provider.extract_text(args["document_id"])
if name == "get_tables":
return provider.get_tables(args["document_id"])
if name == "list_images":
return provider.list_images(args["document_id"])
if name == "list_hyperlinks":
return provider.list_hyperlinks(args["document_id"])
if name == "get_fields_summary":
return provider.get_fields_summary(args["document_id"])
if name == "strip_personal_info":
return provider.strip_personal_info(args["document_id"])
if name == "get_metadata":
return provider.get_metadata(args["document_id"])
if name == "save_document":
return provider.save_document(
args["document_id"],
args["output_path"],
return_content=args.get("return_content", True),
)
if name == "close_document":
return provider.close_document(args["document_id"])
if name == "list_documents":
return provider.list_documents()
if name == "convert_to_pdf":
return provider.convert_to_pdf(
args["document_id"],
args["output_path"],
prefer_external=bool(args.get("prefer_external", False)),
return_content=args.get("return_content", True),
)
if name == "export_pdf_with_field_refresh":
return provider.export_pdf_with_field_refresh(
args["document_id"],
args["output_path"],
prefer_external=bool(args.get("prefer_external", True)),
return_content=args.get("return_content", True),
)
if name == "convert_to_images":
return provider.convert_to_images(
args["document_id"],
args["output_dir"],
format=args.get("format", "png"),
dpi=int(args.get("dpi", 150)),
return_content=args.get("return_content", True),
)
if name == "convert_to_images_with_preference":
return provider.convert_to_images_with_preference(
args["document_id"],
args["output_dir"],
format=args.get("format", "png"),
dpi=int(args.get("dpi", 150)),
prefer_external=bool(args.get("prefer_external", True)),
return_content=args.get("return_content", True),
)
if name == "merge_documents":
return provider.merge_documents(
args["document_ids"],
args["output_path"],
return_content=args.get("return_content", True),
)
if name == "split_document":
return provider.split_document(
args["document_id"],
args["output_dir"],
return_content=args.get("return_content", True),
)
if name == "get_document_structure":
return provider.get_document_structure(args["document_id"])
if name == "get_outline":
return provider.get_outline(args["document_id"])
if name == "get_ranges":
return provider.get_ranges(args["document_id"], args["selector"])
if name == "replace_range_text":
return provider.replace_range_text(
args["document_id"],
args["range_id"],
args["text"],
return_content=args.get("return_content", False),
)
if name == "set_table_cell_text":
return provider.set_table_cell_text(
args["document_id"],
int(args["table_index"]),
int(args["row"]),
int(args["col"]),
args["text"],
return_content=args.get("return_content", False),
)
if name == "get_document_properties":
return provider.get_document_properties(args["document_id"])
if name == "set_document_properties":
return provider.set_document_properties(
args["document_id"],
title=args.get("title"),
subject=args.get("subject"),
author=args.get("author"),
return_content=args.get("return_content", False),
)
if name == "insert_after_heading":
return provider.insert_after_heading(
args["document_id"],
args["heading_text"],
args["text"],
return_content=args.get("return_content", False),
)
if name == "sanitize_external_links":
return provider.sanitize_external_links(args["document_id"])
if name == "redact_text":
return provider.redact_text(
args["document_id"],
args["pattern"],
use_regex=bool(args.get("use_regex", False)),
whole_word=bool(args.get("whole_word", False)),
case_sensitive=bool(args.get("case_sensitive", False)),
return_content=args.get("return_content", False),
)
if name == "analyze_formatting":
return provider.analyze_formatting(args["document_id"])
if name == "get_word_count":
return provider.get_word_count(args["document_id"])
if name == "search_text":
return provider.search_text(
args["document_id"],
args["search_term"],
case_sensitive=bool(args.get("case_sensitive", False)),
whole_word=bool(args.get("whole_word", False)),
)
if name == "export_to_markdown":
return provider.export_to_markdown(
args["document_id"],
args["output_path"],
return_content=args.get("return_content", True),
)
if name == "export_to_html":
return provider.export_to_html(
args["document_id"],
args["output_path"],
return_content=args.get("return_content", True),
)
if name == "get_security_info":
return provider.get_security_info()
if name == "get_storage_info":
return provider.get_storage_info()
if name == "list_templates":
return list_templates(TEMPLATES_DIR)
if name == "open_template":
return provider.open_template(args["name"], TEMPLATES_DIR)
if name == "generate_from_template":
return provider.generate_from_template(
args["template_name"],
args["output_path"],
args.get("fields") or {},
return_content=args.get("return_content", True),
)
raise ValueError(f"Unknown tool: {name}")
def main():
app = make_app()
host = os.getenv("DOCX_MCP_HTTP_HOST", "0.0.0.0")
port = int(os.getenv("DOCX_MCP_HTTP_PORT", "3000"))
import uvicorn
uvicorn.run(app, host=host, port=port, log_level="info")
if __name__ == "__main__":
main()
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
import os
from typing import List
def list_templates(templates_dir: str) -> dict:
if not os.path.isdir(templates_dir):
return {"templates": []}
templates: List[str] = []
for entry in os.listdir(templates_dir):
path = os.path.join(templates_dir, entry)
if os.path.isfile(path) and entry.lower().endswith(".docx"):
templates.append(entry)
templates.sort()
return {"templates": templates}
def open_template_path(templates_dir: str, name: str) -> str:
path = os.path.join(templates_dir, name)
if not os.path.isfile(path):
raise ValueError(f"Template not found: {name}")
return path
-100
View File
@@ -1,100 +0,0 @@
use docx_mcp::security::{Args, SecurityConfig};
use clap::Parser;
use std::env;
fn reset_env() {
for (k, _) in env::vars() {
if k.starts_with("DOCX_MCP_") {
env::remove_var(k);
}
}
}
#[test]
fn parses_flags_and_lists() {
reset_env();
let argv = [
"docx-mcp",
"--readonly",
"--sandbox",
"--no-external-tools",
"--no-network",
"--whitelist",
"open_document,extract_text,get_metadata",
"--blacklist",
"save_document,add_paragraph",
"--max-size",
"1048576",
"--max-docs",
"10",
];
let args = Args::parse_from(&argv);
assert!(args.readonly);
assert!(args.sandbox);
assert!(args.no_external_tools);
assert!(args.no_network);
assert_eq!(args.max_size, Some(1_048_576));
assert_eq!(args.max_docs, Some(10));
let wl = args.whitelist.clone().unwrap();
assert_eq!(wl, vec![
"open_document".to_string(),
"extract_text".to_string(),
"get_metadata".to_string(),
]);
let bl = args.blacklist.clone().unwrap();
assert_eq!(bl, vec![
"save_document".to_string(),
"add_paragraph".to_string(),
]);
let cfg = SecurityConfig::from_args(args);
assert!(cfg.readonly_mode);
assert!(cfg.sandbox_mode);
assert!(!cfg.allow_external_tools);
assert!(!cfg.allow_network);
assert_eq!(cfg.max_document_size, 1_048_576);
assert_eq!(cfg.max_open_documents, 10);
let wlset = cfg.command_whitelist.unwrap();
assert!(wlset.contains("open_document"));
assert!(wlset.contains("extract_text"));
assert!(wlset.contains("get_metadata"));
let blset = cfg.command_blacklist.unwrap();
assert!(blset.contains("save_document"));
assert!(blset.contains("add_paragraph"));
}
#[test]
fn parses_from_environment() {
reset_env();
env::set_var("DOCX_MCP_READONLY", "true");
env::set_var("DOCX_MCP_SANDBOX", "true");
env::set_var("DOCX_MCP_NO_EXTERNAL_TOOLS", "true");
env::set_var("DOCX_MCP_NO_NETWORK", "true");
env::set_var("DOCX_MCP_WHITELIST", "open_document,extract_text");
env::set_var("DOCX_MCP_BLACKLIST", "save_document");
env::set_var("DOCX_MCP_MAX_SIZE", "2048");
env::set_var("DOCX_MCP_MAX_DOCS", "7");
let cfg = SecurityConfig::from_env();
assert!(cfg.readonly_mode);
assert!(cfg.sandbox_mode);
assert!(!cfg.allow_external_tools);
assert!(!cfg.allow_network);
assert_eq!(cfg.max_document_size, 2048);
assert_eq!(cfg.max_open_documents, 7);
let wl = cfg.command_whitelist.unwrap();
assert!(wl.contains("open_document"));
assert!(wl.contains("extract_text"));
let bl = cfg.command_blacklist.unwrap();
assert!(bl.contains("save_document"));
}
-500
View File
@@ -1,500 +0,0 @@
use anyhow::Result;
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
use docx_mcp::pure_converter::PureRustConverter;
use tempfile::TempDir;
use std::path::{Path, PathBuf};
use std::fs;
use pretty_assertions::assert_eq;
use rstest::*;
fn setup_test_handler_with_content() -> (DocxHandler, String, TempDir) {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new().unwrap();
let doc_id = handler.create_document().unwrap();
// Add comprehensive content for testing
handler.add_heading(&doc_id, "Test Document Title", 1).unwrap();
handler.add_paragraph(&doc_id, "This is a comprehensive test document with various content types.", None).unwrap();
handler.add_heading(&doc_id, "Table Example", 2).unwrap();
let table_data = TableData {
rows: vec![
vec!["Product".to_string(), "Price".to_string(), "Quantity".to_string()],
vec!["Widget A".to_string(), "$10.00".to_string(), "5".to_string()],
vec!["Widget B".to_string(), "$15.00".to_string(), "3".to_string()],
],
headers: Some(vec!["Product".to_string(), "Price".to_string(), "Quantity".to_string()]),
border_style: Some("single".to_string()),
col_widths: None,
merges: None,
cell_shading: None,
};
handler.add_table(&doc_id, table_data).unwrap();
handler.add_heading(&doc_id, "List Example", 2).unwrap();
let list_items = vec![
"First important point".to_string(),
"Second key feature".to_string(),
"Third critical aspect".to_string(),
];
handler.add_list(&doc_id, list_items, false).unwrap();
handler.add_paragraph(&doc_id, "Conclusion: This document demonstrates various formatting capabilities.", None).unwrap();
(handler, doc_id, temp_dir)
}
#[test]
fn test_pure_converter_creation() {
let converter = PureRustConverter::new();
// Just verify it can be created without panicking
assert!(true);
}
#[test]
fn test_extract_text_from_docx() -> Result<()> {
let (handler, doc_id, _temp_dir) = setup_test_handler_with_content();
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let extracted_text = converter.extract_text_from_docx(&metadata.path)?;
// Should contain all the content we added
assert!(extracted_text.contains("Test Document Title"));
assert!(extracted_text.contains("comprehensive test document"));
assert!(extracted_text.contains("Table Example"));
assert!(extracted_text.contains("Widget A"));
assert!(extracted_text.contains("First important point"));
assert!(extracted_text.contains("Conclusion"));
Ok(())
}
#[test]
fn test_extract_text_empty_document() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new().unwrap();
let doc_id = handler.create_document().unwrap();
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let extracted_text = converter.extract_text_from_docx(&metadata.path)?;
// Empty document should return empty or whitespace-only text
assert!(extracted_text.trim().is_empty());
Ok(())
}
#[test]
fn test_convert_docx_to_pdf_basic() -> Result<()> {
let (handler, doc_id, temp_dir) = setup_test_handler_with_content();
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let output_path = temp_dir.path().join("test_output.pdf");
converter.convert_docx_to_pdf(&metadata.path, &output_path)?;
// Verify PDF file was created
assert!(output_path.exists());
// Check file size is reasonable (should be larger than empty PDF)
let file_size = fs::metadata(&output_path)?.len();
assert!(file_size > 1000); // PDF should be at least 1KB
// Verify it's actually a PDF file (starts with PDF signature)
let pdf_content = fs::read(&output_path)?;
assert!(pdf_content.starts_with(b"%PDF"));
Ok(())
}
#[test]
fn test_convert_docx_to_pdf_with_complex_content() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new().unwrap();
let doc_id = handler.create_document().unwrap();
// Add content with special characters and formatting
handler.add_paragraph(&doc_id, "Special characters: éñüñ, 中文, русский, العربية", None)?;
let style = DocxStyle {
font_family: Some("Arial".to_string()),
font_size: Some(16),
bold: Some(true),
italic: Some(false),
underline: Some(true),
color: Some("#FF0000".to_string()),
alignment: Some("center".to_string()),
line_spacing: Some(1.5),
};
handler.add_paragraph(&doc_id, "Bold and underlined text", Some(style))?;
// Add multiple headings
for level in 1..=3 {
handler.add_heading(&doc_id, &format!("Heading Level {}", level), level)?;
}
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let output_path = temp_dir.path().join("complex_output.pdf");
converter.convert_docx_to_pdf(&metadata.path, &output_path)?;
assert!(output_path.exists());
let file_size = fs::metadata(&output_path)?.len();
assert!(file_size > 500); // Should be larger due to more content
Ok(())
}
#[test]
fn test_convert_docx_to_images() -> Result<()> {
let (handler, doc_id, temp_dir) = setup_test_handler_with_content();
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let output_dir = temp_dir.path().join("images");
fs::create_dir_all(&output_dir)?;
let image_paths = converter.convert_docx_to_images(&metadata.path, &output_dir)?;
// Should generate at least one image
assert!(!image_paths.is_empty());
// Verify all generated images exist
for image_path in &image_paths {
assert!(image_path.exists(), "Generated image should exist: {:?}", image_path);
let file_size = fs::metadata(image_path)?.len();
assert!(file_size > 100, "Image file should have reasonable size");
// Verify it's a PNG file (our default format)
if image_path.extension().and_then(|s| s.to_str()) == Some("png") {
let image_content = fs::read(image_path)?;
assert!(image_content.starts_with(&[0x89, 0x50, 0x4E, 0x47]), "Should be valid PNG");
}
}
Ok(())
}
#[test]
fn test_convert_docx_to_images_custom_format() -> Result<()> {
let (handler, doc_id, temp_dir) = setup_test_handler_with_content();
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let output_dir = temp_dir.path().join("jpeg_images");
fs::create_dir_all(&output_dir)?;
let image_paths = converter.convert_docx_to_images_with_format(&metadata.path, &output_dir, "jpeg", 150)?;
assert!(!image_paths.is_empty());
for image_path in &image_paths {
assert!(image_path.exists());
// Verify JPEG format
if image_path.extension().and_then(|s| s.to_str()) == Some("jpg") ||
image_path.extension().and_then(|s| s.to_str()) == Some("jpeg") {
let image_content = fs::read(image_path)?;
assert!(image_content.starts_with(&[0xFF, 0xD8, 0xFF]), "Should be valid JPEG");
}
}
Ok(())
}
#[test]
fn test_pdf_generation_with_embedded_fonts() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new().unwrap();
let doc_id = handler.create_document().unwrap();
// Add text that might require different fonts
handler.add_paragraph(&doc_id, "Regular ASCII text", None)?;
handler.add_paragraph(&doc_id, "Unicode: àáâãäå çèéêë ìíîï ñòóôõö ùúûü ýÿ", None)?;
handler.add_paragraph(&doc_id, "Math symbols: ∑ ∏ ∫ √ ≤ ≥ ≠ ± ∞", None)?;
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let output_path = temp_dir.path().join("embedded_fonts.pdf");
converter.convert_docx_to_pdf(&metadata.path, &output_path)?;
assert!(output_path.exists());
let file_size = fs::metadata(&output_path)?.len();
assert!(file_size > 1000); // Should be larger due to embedded fonts
Ok(())
}
#[test]
fn test_batch_conversion() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new().unwrap();
// Create multiple documents
let mut doc_paths = Vec::new();
for i in 0..3 {
let doc_id = handler.create_document().unwrap();
handler.add_paragraph(&doc_id, &format!("Document {} content", i), None)?;
let metadata = handler.get_metadata(&doc_id)?;
doc_paths.push(metadata.path);
}
let converter = PureRustConverter::new();
let output_dir = temp_dir.path().join("batch_output");
fs::create_dir_all(&output_dir)?;
// Convert all documents to PDF
for (i, doc_path) in doc_paths.iter().enumerate() {
let output_path = output_dir.join(format!("document_{}.pdf", i));
converter.convert_docx_to_pdf(doc_path, &output_path)?;
assert!(output_path.exists());
}
// Verify all PDFs were created
let pdf_files: Vec<_> = fs::read_dir(&output_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().and_then(|s| s.to_str()) == Some("pdf"))
.collect();
assert_eq!(pdf_files.len(), 3);
Ok(())
}
#[test]
fn test_error_handling_invalid_docx() {
let temp_dir = TempDir::new().unwrap();
let converter = PureRustConverter::new();
// Create a fake DOCX file (actually just text)
let fake_docx = temp_dir.path().join("fake.docx");
fs::write(&fake_docx, "This is not a DOCX file").unwrap();
// Should handle the error gracefully
let result = converter.extract_text_from_docx(&fake_docx);
assert!(result.is_err());
let output_path = temp_dir.path().join("output.pdf");
let result = converter.convert_docx_to_pdf(&fake_docx, &output_path);
assert!(result.is_err());
}
#[test]
fn test_error_handling_nonexistent_file() {
let temp_dir = TempDir::new().unwrap();
let converter = PureRustConverter::new();
let nonexistent = temp_dir.path().join("nonexistent.docx");
let result = converter.extract_text_from_docx(&nonexistent);
assert!(result.is_err());
let output_path = temp_dir.path().join("output.pdf");
let result = converter.convert_docx_to_pdf(&nonexistent, &output_path);
assert!(result.is_err());
}
#[test]
fn test_large_document_conversion() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new().unwrap();
let doc_id = handler.create_document().unwrap();
// Create a large document with many pages
for i in 0..50 {
handler.add_heading(&doc_id, &format!("Section {}", i + 1), 1)?;
for j in 0..10 {
let content = format!("This is paragraph {} in section {}. It contains enough text to make the document substantial and test the conversion capabilities with larger files.", j + 1, i + 1);
handler.add_paragraph(&doc_id, &content, None)?;
}
if i % 10 == 9 {
handler.add_page_break(&doc_id)?;
}
}
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
// Test PDF conversion
let pdf_path = temp_dir.path().join("large_document.pdf");
converter.convert_docx_to_pdf(&metadata.path, &pdf_path)?;
assert!(pdf_path.exists());
let pdf_size = fs::metadata(&pdf_path)?.len();
assert!(pdf_size > 50000); // Should be a substantial PDF
// Test image conversion (but only first few pages to avoid excessive test time)
let images_dir = temp_dir.path().join("large_images");
fs::create_dir_all(&images_dir)?;
let image_paths = converter.convert_docx_to_images(&metadata.path, &images_dir)?;
assert!(!image_paths.is_empty());
// Should generate multiple images for multiple pages
assert!(image_paths.len() >= 2);
Ok(())
}
#[test]
fn test_text_extraction_accuracy() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new().unwrap();
let doc_id = handler.create_document().unwrap();
// Add specific test content
let test_sentences = vec![
"The quick brown fox jumps over the lazy dog.",
"Pack my box with five dozen liquor jugs.",
"How vexingly quick daft zebras jump!",
"Sphinx of black quartz, judge my vow.",
];
for sentence in &test_sentences {
handler.add_paragraph(&doc_id, sentence, None)?;
}
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let extracted_text = converter.extract_text_from_docx(&metadata.path)?;
// Verify all sentences are present in the extracted text
for sentence in &test_sentences {
assert!(extracted_text.contains(sentence),
"Extracted text should contain: '{}'", sentence);
}
// Check word count accuracy
let expected_words: usize = test_sentences.iter()
.map(|s| s.split_whitespace().count())
.sum();
let extracted_words = extracted_text.split_whitespace().count();
// Should be approximately equal (allowing for minor differences)
let word_diff = if extracted_words > expected_words {
extracted_words - expected_words
} else {
expected_words - extracted_words
};
assert!(word_diff <= 5, "Word count difference too large: expected ~{}, got {}", expected_words, extracted_words);
Ok(())
}
#[test]
fn test_conversion_with_different_page_sizes() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new().unwrap();
let doc_id = handler.create_document().unwrap();
handler.add_paragraph(&doc_id, "This document tests page size handling during conversion.", None)?;
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
// Test different output formats and sizes
let test_cases = vec![
("a4.pdf", "A4"),
("letter.pdf", "Letter"),
("legal.pdf", "Legal"),
];
for (filename, _page_size) in test_cases {
let output_path = temp_dir.path().join(filename);
// Note: In a full implementation, you'd pass page_size to the converter
converter.convert_docx_to_pdf(&metadata.path, &output_path)?;
assert!(output_path.exists());
let file_size = fs::metadata(&output_path)?.len();
assert!(file_size > 500); // Reasonable minimum size
}
Ok(())
}
// Parametrized test for different image formats
#[rstest]
#[case("png", &[0x89, 0x50, 0x4E, 0x47])]
#[case("jpeg", &[0xFF, 0xD8, 0xFF])]
fn test_image_format_conversion(#[case] format: &str, #[case] signature: &[u8]) -> Result<()> {
let (handler, doc_id, temp_dir) = setup_test_handler_with_content();
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let output_dir = temp_dir.path().join(format!("{}_images", format));
fs::create_dir_all(&output_dir)?;
let image_paths = converter.convert_docx_to_images_with_format(&metadata.path, &output_dir, format, 100)?;
assert!(!image_paths.is_empty());
for image_path in &image_paths {
assert!(image_path.exists());
let image_content = fs::read(image_path)?;
assert!(image_content.starts_with(signature),
"Image should have correct format signature for {}", format);
}
Ok(())
}
#[test]
fn test_conversion_thread_safety() -> Result<()> {
use std::sync::Arc;
use std::thread;
let temp_dir = TempDir::new().unwrap();
let temp_path = Arc::new(temp_dir.path().to_path_buf());
let handles: Vec<_> = (0..3).map(|i| {
let temp_path = Arc::clone(&temp_path);
thread::spawn(move || -> Result<()> {
let mut handler = DocxHandler::new()?;
let doc_id = handler.create_document()?;
handler.add_paragraph(&doc_id, &format!("Thread {} test content", i), None)?;
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let pdf_path = temp_path.join(format!("thread_{}.pdf", i));
converter.convert_docx_to_pdf(&metadata.path, &pdf_path)?;
assert!(pdf_path.exists());
Ok(())
})
}).collect();
// Wait for all threads to complete
for handle in handles {
handle.join().unwrap()?;
}
// Verify all PDFs were created
let pdf_count = fs::read_dir(&temp_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().and_then(|s| s.to_str()) == Some("pdf"))
.count();
assert_eq!(pdf_count, 3);
Ok(())
}
-317
View File
@@ -1,317 +0,0 @@
use anyhow::Result;
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
use tempfile::TempDir;
use std::path::PathBuf;
use pretty_assertions::assert_eq;
use rstest::*;
use chrono::Utc;
fn setup_test_handler() -> (DocxHandler, TempDir) {
let temp_dir = TempDir::new().unwrap();
let handler = DocxHandler::new().unwrap();
(handler, temp_dir)
}
#[fixture]
fn handler_and_doc() -> (DocxHandler, String, TempDir) {
let (mut handler, temp_dir) = setup_test_handler();
let doc_id = handler.create_document().unwrap();
(handler, doc_id, temp_dir)
}
#[test]
fn test_create_document() {
let (mut handler, _temp_dir) = setup_test_handler();
let doc_id = handler.create_document().unwrap();
assert!(!doc_id.is_empty());
// Document should be in the handler's registry
assert!(handler.documents.contains_key(&doc_id));
let metadata = handler.get_metadata(&doc_id).unwrap();
assert_eq!(metadata.id, doc_id);
assert!(metadata.path.exists());
}
#[test]
fn test_add_paragraph() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
let result = handler.add_paragraph(&doc_id, "Test paragraph", None);
assert!(result.is_ok());
// Verify content was added by extracting text
let text = handler.extract_text(&doc_id).unwrap();
assert!(text.contains("Test paragraph"));
}
#[test]
fn test_add_paragraph_with_style() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
let style = DocxStyle {
font_family: Some("Arial".to_string()),
font_size: Some(14),
bold: Some(true),
italic: Some(false),
underline: Some(false),
color: Some("#FF0000".to_string()),
alignment: Some("center".to_string()),
line_spacing: Some(1.5),
};
let result = handler.add_paragraph(&doc_id, "Styled paragraph", Some(style));
assert!(result.is_ok());
let text = handler.extract_text(&doc_id).unwrap();
assert!(text.contains("Styled paragraph"));
}
#[test]
fn test_add_heading() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
for level in 1..=6 {
let heading_text = format!("Heading Level {}", level);
let result = handler.add_heading(&doc_id, &heading_text, level);
assert!(result.is_ok(), "Failed to add heading level {}", level);
let text = handler.extract_text(&doc_id).unwrap();
assert!(text.contains(&heading_text));
}
}
#[test]
fn test_add_table() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
let table_data = TableData {
rows: vec![
vec!["Name".to_string(), "Age".to_string(), "City".to_string()],
vec!["John".to_string(), "30".to_string(), "NYC".to_string()],
vec!["Jane".to_string(), "25".to_string(), "LA".to_string()],
],
headers: Some(vec!["Name".to_string(), "Age".to_string(), "City".to_string()]),
border_style: Some("single".to_string()),
col_widths: None,
merges: None,
cell_shading: None,
};
let result = handler.add_table(&doc_id, table_data);
assert!(result.is_ok());
let text = handler.extract_text(&doc_id).unwrap();
assert!(text.contains("John"));
assert!(text.contains("Jane"));
assert!(text.contains("NYC"));
}
#[test]
fn test_add_list() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
let items = vec![
"First item".to_string(),
"Second item".to_string(),
"Third item".to_string(),
];
// Test unordered list
let result = handler.add_list(&doc_id, items.clone(), false);
assert!(result.is_ok());
// Test ordered list
let result = handler.add_list(&doc_id, items.clone(), true);
assert!(result.is_ok());
let text = handler.extract_text(&doc_id).unwrap();
assert!(text.contains("First item"));
assert!(text.contains("Second item"));
assert!(text.contains("Third item"));
}
#[test]
fn test_set_header_footer() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
let header_result = handler.set_header(&doc_id, "Document Header");
assert!(header_result.is_ok());
let footer_result = handler.set_footer(&doc_id, "Document Footer");
assert!(footer_result.is_ok());
}
#[test]
fn test_add_page_break() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
handler.add_paragraph(&doc_id, "Before page break", None).unwrap();
let result = handler.add_page_break(&doc_id);
assert!(result.is_ok());
handler.add_paragraph(&doc_id, "After page break", None).unwrap();
let text = handler.extract_text(&doc_id).unwrap();
assert!(text.contains("Before page break"));
assert!(text.contains("After page break"));
}
#[test]
fn test_extract_text_empty_document() {
let (handler, doc_id, _temp_dir) = handler_and_doc();
let text = handler.extract_text(&doc_id).unwrap();
// Empty document might have some default content or be truly empty
assert!(text.is_empty() || text.trim().is_empty());
}
#[test]
fn test_save_and_close_document() {
let (mut handler, doc_id, temp_dir) = handler_and_doc();
handler.add_paragraph(&doc_id, "Test content", None).unwrap();
let save_path = temp_dir.path().join("test_output.docx");
let save_result = handler.save_document(&doc_id, &save_path);
assert!(save_result.is_ok());
assert!(save_path.exists());
let close_result = handler.close_document(&doc_id);
assert!(close_result.is_ok());
assert!(!handler.documents.contains_key(&doc_id));
}
#[test]
fn test_open_existing_document() {
let (mut handler, doc_id, temp_dir) = handler_and_doc();
// Create and save a document
handler.add_paragraph(&doc_id, "Original content", None).unwrap();
let save_path = temp_dir.path().join("existing.docx");
handler.save_document(&doc_id, &save_path).unwrap();
handler.close_document(&doc_id).unwrap();
// Open the saved document
let opened_doc_id = handler.open_document(&save_path).unwrap();
assert_ne!(opened_doc_id, doc_id); // Should be a new ID
let text = handler.extract_text(&opened_doc_id).unwrap();
assert!(text.contains("Original content"));
}
#[test]
fn test_list_documents() {
let (mut handler, _temp_dir) = setup_test_handler();
// Initially should be empty
let docs = handler.list_documents();
let initial_count = docs.len();
// Create some documents
let _doc1 = handler.create_document().unwrap();
let _doc2 = handler.create_document().unwrap();
let _doc3 = handler.create_document().unwrap();
let docs = handler.list_documents();
assert_eq!(docs.len(), initial_count + 3);
}
#[test]
fn test_document_not_found_error() {
let (handler, _temp_dir) = setup_test_handler();
let fake_id = "nonexistent-document-id";
let result = handler.extract_text(fake_id);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Document not found"));
}
#[test]
fn test_get_metadata() {
let (handler, doc_id, _temp_dir) = handler_and_doc();
let metadata = handler.get_metadata(&doc_id).unwrap();
assert_eq!(metadata.id, doc_id);
assert!(metadata.path.exists());
assert!(metadata.created_at <= Utc::now());
assert!(metadata.modified_at <= Utc::now());
assert_eq!(metadata.page_count, Some(1));
assert_eq!(metadata.word_count, Some(0));
}
#[test]
fn test_concurrent_document_operations() {
use std::sync::Arc;
use std::sync::Mutex;
use std::thread;
let (handler, _temp_dir) = setup_test_handler();
let handler = Arc::new(Mutex::new(handler));
let handles: Vec<_> = (0..5).map(|i| {
let handler = Arc::clone(&handler);
thread::spawn(move || {
let doc_id = {
let mut h = handler.lock().unwrap();
h.create_document().unwrap()
};
{
let mut h = handler.lock().unwrap();
h.add_paragraph(&doc_id, &format!("Thread {} content", i), None).unwrap();
}
{
let h = handler.lock().unwrap();
let text = h.extract_text(&doc_id).unwrap();
assert!(text.contains(&format!("Thread {} content", i)));
}
doc_id
})
}).collect();
let doc_ids: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// All documents should be different
let mut unique_ids = doc_ids.clone();
unique_ids.sort();
unique_ids.dedup();
assert_eq!(unique_ids.len(), doc_ids.len());
}
#[test]
fn test_large_document_creation() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
// Add many paragraphs to test performance
for i in 0..100 {
let content = format!("Paragraph number {} with some content to make it realistic", i);
handler.add_paragraph(&doc_id, &content, None).unwrap();
}
let text = handler.extract_text(&doc_id).unwrap();
assert!(text.contains("Paragraph number 0"));
assert!(text.contains("Paragraph number 99"));
// Verify word count (lower threshold due to simplified text extraction)
let words: Vec<&str> = text.split_whitespace().collect();
assert!(words.len() > 300);
}
#[test]
fn test_special_characters_in_content() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
let special_content = "Special chars: éñüñdéd, 中文, русский, العربية, 🚀📝✨";
handler.add_paragraph(&doc_id, special_content, None).unwrap();
let text = handler.extract_text(&doc_id).unwrap();
assert!(text.contains("éñüñdéd"));
assert!(text.contains("🚀📝✨"));
}
-933
View File
@@ -1,933 +0,0 @@
use anyhow::Result;
use docx_mcp::docx_tools::DocxToolsProvider;
use docx_mcp::security::SecurityConfig;
use mcp_core::types::ToolResponseContent;
use serde_json::{json, Value};
use tempfile::TempDir;
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use pretty_assertions::assert_eq;
// tokio_test not needed in async tests here
enum ToolResult {
Success(Value),
Error(String),
}
async fn tool_result(provider: &DocxToolsProvider, name: &str, args: Value) -> ToolResult {
let resp = provider.call_tool(name, args).await;
let val = match resp.content.get(0) {
Some(ToolResponseContent::Text(t)) => serde_json::from_str::<Value>(&t.text)
.unwrap_or_else(|_| json!({"success": false, "error": t.text.clone()})),
_ => json!({"success": false, "error": "non-text response"}),
};
if val.get("success").and_then(|v| v.as_bool()).unwrap_or(false) {
ToolResult::Success(val)
} else {
let err = val.get("error").and_then(|v| v.as_str()).unwrap_or("Unknown error").to_string();
ToolResult::Error(err)
}
}
/// Test complete document creation workflow from start to finish
#[tokio::test]
async fn test_complete_document_workflow() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let provider = DocxToolsProvider::with_base_dir(temp_dir.path());
// Step 1: Create a new document
let create_result = tool_result(&provider, "create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
value["document_id"].as_str().unwrap().to_string()
},
ToolResult::Error(e) => panic!("Failed to create document: {}", e),
};
// Step 2: Add document structure
let title_result = tool_result(&provider, "add_heading", json!({
"document_id": doc_id,
"text": "Annual Report 2024",
"level": 1
})).await;
assert!(matches!(title_result, ToolResult::Success(_)), "add_heading failed at start");
// Step 3: Add introduction
let intro_result = tool_result(&provider, "add_paragraph", json!({
"document_id": doc_id,
"text": "This annual report provides a comprehensive overview of our company's performance, achievements, and strategic direction for the year 2024.",
"style": {
"font_size": 12,
"alignment": "justify"
}
})).await;
assert!(matches!(intro_result, ToolResult::Success(_)));
// Step 4: Add executive summary section
let exec_heading_result = tool_result(&provider, "add_heading", json!({
"document_id": doc_id,
"text": "Executive Summary",
"level": 2
})).await;
assert!(matches!(exec_heading_result, ToolResult::Success(_)));
let exec_content = tool_result(&provider, "add_list", json!({
"document_id": doc_id,
"items": [
"Record revenue growth of 15% year-over-year",
"Successful expansion into three new markets",
"Launch of five innovative products",
"Achievement of carbon neutrality goals",
"Increased employee satisfaction by 20%"
],
"ordered": false
})).await;
assert!(matches!(exec_content, ToolResult::Success(_)));
// Step 5: Add financial data table
let financial_heading = tool_result(&provider, "add_heading", json!({
"document_id": doc_id,
"text": "Financial Highlights",
"level": 2
})).await;
assert!(matches!(financial_heading, ToolResult::Success(_)));
let table_result = tool_result(&provider, "add_table", json!({
"document_id": doc_id,
"rows": [
["Metric", "2023", "2024", "Change"],
["Revenue ($M)", "120.5", "138.6", "+15%"],
["Operating Income ($M)", "24.1", "29.3", "+22%"],
["Net Income ($M)", "18.2", "22.7", "+25%"],
["Employees", "1,250", "1,420", "+14%"]
]
})).await;
assert!(matches!(table_result, ToolResult::Success(_)));
// Step 6: Add page break and new section
let page_break_result = tool_result(&provider, "add_page_break", json!({
"document_id": doc_id
})).await;
assert!(matches!(page_break_result, ToolResult::Success(_)));
let strategy_heading = tool_result(&provider, "add_heading", json!({
"document_id": doc_id,
"text": "Strategic Initiatives",
"level": 2
})).await;
assert!(matches!(strategy_heading, ToolResult::Success(_)));
// Step 7: Add multiple paragraphs with different styles
let bold_paragraph = tool_result(&provider, "add_paragraph", json!({
"document_id": doc_id,
"text": "Digital Transformation: Our commitment to digital innovation remains at the forefront of our strategic priorities.",
"style": {
"bold": true,
"font_size": 13
}
})).await;
assert!(matches!(bold_paragraph, ToolResult::Success(_)));
let regular_paragraph = tool_result(&provider, "add_paragraph", json!({
"document_id": doc_id,
"text": "Throughout 2024, we have invested significantly in technology infrastructure, data analytics capabilities, and employee digital skills development. This comprehensive approach has resulted in improved operational efficiency and enhanced customer experience across all touchpoints."
})).await;
assert!(matches!(regular_paragraph, ToolResult::Success(_)));
// Step 8: Set document header and footer
let header_result = tool_result(&provider, "set_header", json!({
"document_id": doc_id,
"text": "Annual Report 2024 | Confidential"
})).await;
assert!(matches!(header_result, ToolResult::Success(_)));
let footer_result = tool_result(&provider, "set_footer", json!({
"document_id": doc_id,
"text": "© 2024 Company Name. All rights reserved."
})).await;
assert!(matches!(footer_result, ToolResult::Success(_)));
// Step 9: Verify document content
let extract_result = tool_result(&provider, "extract_text", json!({
"document_id": doc_id
})).await;
match extract_result {
ToolResult::Success(value) => {
let text = value["text"].as_str().unwrap();
// Verify all content is present
assert!(text.contains("Annual Report 2024"));
assert!(text.contains("Executive Summary"));
assert!(text.contains("Record revenue growth"));
assert!(text.contains("Financial Highlights"));
assert!(text.contains("Revenue ($M)"));
assert!(text.contains("138.6"));
assert!(text.contains("Strategic Initiatives"));
assert!(text.contains("Digital Transformation"));
println!("Document contains {} characters of text", text.len());
assert!(text.len() > 600, "Document should have substantial content");
},
ToolResult::Error(e) => panic!("Failed to extract text: {}", e),
}
// Step 10: Get document metadata
let metadata_result = tool_result(&provider, "get_metadata", json!({
"document_id": doc_id
})).await;
match metadata_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let metadata = &value["metadata"];
assert_eq!(metadata["id"], doc_id);
assert!(metadata["path"].is_string());
},
ToolResult::Error(e) => panic!("Failed to get metadata: {}", e),
}
// Step 11: Export to different formats
let output_dir = temp_dir.path().join("exports");
fs::create_dir_all(&output_dir)?;
// Export to PDF
let pdf_path = output_dir.join("annual_report.pdf");
let pdf_result = tool_result(&provider, "convert_to_pdf", json!({
"document_id": doc_id,
"output_path": pdf_path.to_str().unwrap()
})).await;
assert!(matches!(pdf_result, ToolResult::Success(_)));
assert!(pdf_path.exists());
// Export to markdown
let md_path = output_dir.join("annual_report.md");
let md_result = tool_result(&provider, "export_to_markdown", json!({
"document_id": doc_id,
"output_path": md_path.to_str().unwrap()
})).await;
assert!(matches!(md_result, ToolResult::Success(_)));
assert!(md_path.exists());
// Step 12: Save the original document
let save_path = output_dir.join("annual_report.docx");
let save_result = tool_result(&provider, "save_document", json!({
"document_id": doc_id,
"output_path": save_path.to_str().unwrap()
})).await;
assert!(matches!(save_result, ToolResult::Success(_)));
assert!(save_path.exists());
println!("Complete workflow test successful! Generated files:");
println!("- PDF: {:?}", pdf_path);
println!("- Markdown: {:?}", md_path);
println!("- DOCX: {:?}", save_path);
Ok(())
}
/// Test document editing and revision workflow
#[tokio::test]
async fn test_document_editing_workflow() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let provider = DocxToolsProvider::with_base_dir(temp_dir.path());
// Create initial document
let create_result = tool_result(&provider, "create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Add initial content
tool_result(&provider, "add_heading", json!({
"document_id": doc_id,
"text": "Project Status Report",
"level": 1
})).await;
tool_result(&provider, "add_paragraph", json!({
"document_id": doc_id,
"text": "Current project status and upcoming milestones."
})).await;
// Add tasks list
tool_result(&provider, "add_heading", json!({
"document_id": doc_id,
"text": "Current Tasks",
"level": 2
})).await;
tool_result(&provider, "add_list", json!({
"document_id": doc_id,
"items": [
"Complete user interface design",
"Implement backend API",
"Write unit tests",
"Deploy to staging environment"
],
"ordered": true
})).await;
// Search for specific content
let search_result = tool_result(&provider, "search_text", json!({
"document_id": doc_id,
"search_term": "backend",
"case_sensitive": false
})).await;
match search_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let matches = value["matches"].as_array().unwrap();
assert!(!matches.is_empty());
assert!(value["total_matches"].as_u64().unwrap() > 0);
},
ToolResult::Error(e) => panic!("Search failed: {}", e),
}
// Get word count before modifications
let word_count_before = tool_result(&provider, "get_word_count", json!({
"document_id": doc_id
})).await;
let initial_word_count = match word_count_before {
ToolResult::Success(value) => {
value["statistics"]["words"].as_u64().unwrap()
},
_ => panic!("Failed to get word count"),
};
// Add more content (simulating document expansion)
tool_result(&provider, "add_heading", json!({
"document_id": doc_id,
"text": "Completed Items",
"level": 2
})).await;
tool_result(&provider, "add_table", json!({
"document_id": doc_id,
"rows": [
["Task", "Completed Date", "Notes"],
["Requirements gathering", "2024-01-15", "All stakeholders interviewed"],
["Architecture design", "2024-01-22", "Approved by tech committee"],
["Database schema", "2024-01-28", "Optimized for performance"]
]
})).await;
// Add risks section
tool_result(&provider, "add_heading", json!({
"document_id": doc_id,
"text": "Identified Risks",
"level": 2
})).await;
tool_result(&provider, "add_paragraph", json!({
"document_id": doc_id,
"text": "The following risks have been identified and mitigation strategies are in place:",
"style": {
"italic": true
}
})).await;
tool_result(&provider, "add_list", json!({
"document_id": doc_id,
"items": [
"Resource constraints may delay delivery",
"Third-party API changes could impact integration",
"Security requirements may require additional development time"
],
"ordered": false
})).await;
// Get word count after modifications
let word_count_after = tool_result(&provider, "get_word_count", json!({
"document_id": doc_id
})).await;
let final_word_count = match word_count_after {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let stats = &value["statistics"];
let words = stats["words"].as_u64().unwrap();
let chars = stats["characters"].as_u64().unwrap();
let sentences = stats["sentences"].as_u64().unwrap();
println!("Document statistics: {} words, {} characters, {} sentences",
words, chars, sentences);
assert!(words > 0);
assert!(chars > 0);
assert!(sentences > 0);
words
},
ToolResult::Error(e) => panic!("Failed to get final word count: {}", e),
};
// Verify document grew
assert!(final_word_count > initial_word_count,
"Document should have more words after additions: {} -> {}",
initial_word_count, final_word_count);
// Perform find and replace operation
let replace_result = tool_result(&provider, "find_and_replace", json!({
"document_id": doc_id,
"find_text": "backend",
"replace_text": "server-side",
"case_sensitive": false
})).await;
match replace_result {
ToolResult::Success(value) => {
// Note: The actual implementation might return different result structure
println!("Find and replace completed: {:?}", value);
},
ToolResult::Error(_) => {
// This is acceptable as find_and_replace might not be fully implemented
println!("Find and replace not fully implemented yet");
}
}
// Final verification
let final_text = tool_result(&provider, "extract_text", json!({
"document_id": doc_id
})).await;
match final_text {
ToolResult::Success(value) => {
let text = value["text"].as_str().unwrap();
// Verify all sections are present
assert!(text.contains("Project Status Report"));
assert!(text.contains("Current Tasks"));
assert!(text.contains("Completed Items"));
assert!(text.contains("Identified Risks"));
assert!(text.contains("Requirements gathering"));
assert!(text.contains("Resource constraints"));
println!("Final document contains {} characters", text.len());
},
ToolResult::Error(e) => panic!("Failed to extract final text: {}", e),
}
Ok(())
}
/// Test collaborative workflow with multiple document operations
#[tokio::test]
async fn test_collaborative_workflow() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let provider = DocxToolsProvider::with_base_dir(temp_dir.path());
let mut document_ids = Vec::new();
// Simulate multiple team members creating documents
let team_members = vec!["Alice", "Bob", "Charlie"];
for member in &team_members {
// Each member creates a document
let create_result = tool_result(&provider, "create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document for {}", member),
};
// Add member-specific content
tool_result(&provider, "add_heading", json!({
"document_id": doc_id,
"text": format!("{}'s Weekly Report", member),
"level": 1
})).await;
tool_result(&provider, "add_paragraph", json!({
"document_id": doc_id,
"text": format!("This week, {} focused on the following activities and achievements.", member)
})).await;
// Add achievements
let achievements = match member {
&"Alice" => vec![
"Completed user research interviews",
"Created wireframes for new features",
"Updated design system documentation"
],
&"Bob" => vec![
"Implemented new API endpoints",
"Optimized database queries",
"Fixed critical security vulnerability"
],
&"Charlie" => vec![
"Deployed version 2.1 to production",
"Set up monitoring dashboards",
"Conducted security audit"
],
_ => vec!["General tasks completed"],
};
provider.call_tool("add_list", json!({
"document_id": doc_id,
"items": achievements,
"ordered": false
})).await;
// Add metrics table
provider.call_tool("add_heading", json!({
"document_id": doc_id,
"text": "Key Metrics",
"level": 2
})).await;
let metrics = match member {
&"Alice" => vec![
vec!["Interviews Conducted", "8"],
vec!["Designs Created", "12"],
vec!["User Stories", "15"]
],
&"Bob" => vec![
vec!["Lines of Code", "2,450"],
vec!["Tests Written", "23"],
vec!["Bugs Fixed", "7"]
],
&"Charlie" => vec![
vec!["Deployments", "3"],
vec!["Issues Resolved", "11"],
vec!["System Uptime", "99.9%"]
],
_ => vec![vec!["Tasks", "5"]],
};
let mut table_rows = vec![vec!["Metric".to_string(), "Value".to_string()]];
for metric in metrics {
table_rows.push(metric.iter().map(|s| s.to_string()).collect());
}
provider.call_tool("add_table", json!({
"document_id": doc_id,
"rows": table_rows
})).await;
document_ids.push((member.to_string(), doc_id));
}
// List all documents
let list_result = tool_result(&provider, "list_documents", json!({})).await;
match list_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let documents = value["documents"].as_array().unwrap();
assert!(documents.len() >= 3, "Should have at least 3 documents");
println!("Found {} documents in the system", documents.len());
},
ToolResult::Error(e) => panic!("Failed to list documents: {}", e),
}
// Generate a summary document combining all reports
let summary_result = tool_result(&provider, "create_document", json!({})).await;
let summary_id = match summary_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
ToolResult::Error(e) => panic!("Failed to create summary document: {}", e),
};
// Add summary header
tool_result(&provider, "add_heading", json!({
"document_id": summary_id,
"text": "Team Weekly Summary Report",
"level": 1
})).await;
tool_result(&provider, "add_paragraph", json!({
"document_id": summary_id,
"text": "This document summarizes the key activities and achievements from all team members this week."
})).await;
// Add content from each team member's document
for (member, doc_id) in &document_ids {
tool_result(&provider, "add_heading", json!({
"document_id": summary_id,
"text": format!("{} Highlights", member),
"level": 2
})).await;
// Extract text from member's document
let extract_result = tool_result(&provider, "extract_text", json!({
"document_id": doc_id
})).await;
match extract_result {
ToolResult::Success(value) => {
let text = value["text"].as_str().unwrap();
// Extract key points (simplified - would be more sophisticated in real implementation)
let lines: Vec<&str> = text.lines().collect();
let summary_text = if lines.len() > 10 {
format!("Key activities include multiple achievements in their focus areas. Full details available in {}'s individual report.", member)
} else {
format!("Summary content from {}'s report.", member)
};
tool_result(&provider, "add_paragraph", json!({
"document_id": summary_id,
"text": summary_text
})).await;
},
ToolResult::Error(e) => {
println!("Warning: Could not extract text from {}'s document: {}", member, e);
}
}
}
// Add team totals table
tool_result(&provider, "add_heading", json!({
"document_id": summary_id,
"text": "Team Totals",
"level": 2
})).await;
tool_result(&provider, "add_table", json!({
"document_id": summary_id,
"rows": [
["Team Member", "Documents Created", "Key Focus"],
["Alice", "1", "Design & Research"],
["Bob", "1", "Development & Security"],
["Charlie", "1", "Operations & Deployment"],
["Total", "3", "Full-stack delivery"]
]
})).await;
// Convert all documents to PDF for archival
let archive_dir = temp_dir.path().join("weekly_archive");
fs::create_dir_all(&archive_dir)?;
for (member, doc_id) in &document_ids {
let pdf_path = archive_dir.join(format!("{}_weekly_report.pdf", member.to_lowercase()));
tool_result(&provider, "convert_to_pdf", json!({
"document_id": doc_id,
"output_path": pdf_path.to_str().unwrap()
})).await;
if pdf_path.exists() {
println!("Archived {}'s report to PDF", member);
}
}
// Archive summary document
let summary_pdf = archive_dir.join("team_summary.pdf");
tool_result(&provider, "convert_to_pdf", json!({
"document_id": summary_id,
"output_path": summary_pdf.to_str().unwrap()
})).await;
// Verify all PDFs were created
let pdf_count = fs::read_dir(&archive_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().and_then(|s| s.to_str()) == Some("pdf"))
.count();
assert!(pdf_count >= 3, "Should have created at least 3 PDF files");
println!("Successfully archived {} PDF documents", pdf_count);
Ok(())
}
/// Test security-restricted workflow
#[tokio::test]
async fn test_security_restricted_workflow() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
// Create a restrictive security configuration
let mut whitelist = HashSet::new();
whitelist.insert("open_document".to_string());
whitelist.insert("extract_text".to_string());
whitelist.insert("get_metadata".to_string());
whitelist.insert("search_text".to_string());
whitelist.insert("get_word_count".to_string());
whitelist.insert("list_documents".to_string());
whitelist.insert("get_security_info".to_string());
let security_config = SecurityConfig {
readonly_mode: true,
sandbox_mode: true,
command_whitelist: Some(whitelist),
command_blacklist: None,
max_document_size: 1024 * 1024, // 1MB
max_open_documents: 5,
allow_external_tools: false,
allow_network: false,
};
let provider = DocxToolsProvider::with_base_dir_and_security(temp_dir.path(), security_config);
// Test security info
let security_info = tool_result(&provider, "get_security_info", json!({})).await;
match security_info {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let security = &value["security"];
assert_eq!(security["readonly_mode"], true);
assert_eq!(security["sandbox_mode"], true);
println!("Security configuration: {}", security["summary"].as_str().unwrap());
},
ToolResult::Error(e) => panic!("Failed to get security info: {}", e),
}
// Test that write operations are blocked
let create_result = tool_result(&provider, "create_document", json!({})).await;
match create_result {
ToolResult::Success(value) => {
// Should fail security check
assert!(!value.get("success").unwrap_or(&json!(true)).as_bool().unwrap());
},
ToolResult::Error(e) => {
assert!(e.contains("Security check failed") || e.contains("Command not allowed"));
println!("Create document correctly blocked: {}", e);
}
}
// Test that add_paragraph is blocked
let paragraph_result = tool_result(&provider, "add_paragraph", json!({
"document_id": "test",
"text": "This should be blocked"
})).await;
match paragraph_result {
ToolResult::Success(value) => {
assert!(!value.get("success").unwrap_or(&json!(true)).as_bool().unwrap());
},
ToolResult::Error(e) => {
assert!(e.contains("Security check failed") || e.contains("Command not allowed"));
println!("Add paragraph correctly blocked: {}", e);
}
}
// Create a test document externally (outside security restrictions)
let unrestricted_provider = DocxToolsProvider::new();
let create_result = tool_result(&unrestricted_provider, "create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create test document"),
};
// Add content with unrestricted provider
tool_result(&unrestricted_provider, "add_heading", json!({
"document_id": doc_id,
"text": "Security Test Document",
"level": 1
})).await;
tool_result(&unrestricted_provider, "add_paragraph", json!({
"document_id": doc_id,
"text": "This document is used to test readonly access capabilities in a security-restricted environment."
})).await;
tool_result(&unrestricted_provider, "add_list", json!({
"document_id": doc_id,
"items": [
"Test text extraction",
"Test search functionality",
"Test metadata retrieval",
"Test word counting"
],
"ordered": true
})).await;
// Save document to a sandbox-allowed path and reopen it under restricted provider
// Use OS temp dir root to satisfy sandbox canonicalization
let saved_path = std::env::temp_dir().join("docx-mcp").join("restricted_source.docx");
std::fs::create_dir_all(saved_path.parent().unwrap()).unwrap();
tool_result(&unrestricted_provider, "save_document", json!({
"document_id": doc_id,
"output_path": saved_path.to_str().unwrap()
})).await;
// Open under restricted provider to import into its registry
let opened = tool_result(&provider, "open_document", json!({
"path": saved_path.to_str().unwrap()
})).await;
let doc_id = match opened {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
ToolResult::Error(e) => panic!("Restricted provider failed to open saved document: {}", e),
};
// Now test readonly operations with restricted provider
// These should work because they're in the whitelist
// Test text extraction
let extract_result = tool_result(&provider, "extract_text", json!({
"document_id": doc_id
})).await;
match extract_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let text = value["text"].as_str().unwrap();
assert!(text.contains("Security Test Document"));
assert!(text.contains("Test text extraction"));
println!("Text extraction successful: {} characters", text.len());
},
ToolResult::Error(e) => panic!("Text extraction should work: {}", e),
}
// Test search functionality
let search_result = tool_result(&provider, "search_text", json!({
"document_id": doc_id,
"search_term": "security",
"case_sensitive": false
})).await;
match search_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
assert!(value["total_matches"].as_u64().unwrap() > 0);
println!("Search successful: found {} matches", value["total_matches"]);
},
ToolResult::Error(e) => panic!("Search should work: {}", e),
}
// Test metadata retrieval
let metadata_result = tool_result(&provider, "get_metadata", json!({
"document_id": doc_id
})).await;
match metadata_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let metadata = &value["metadata"];
assert_eq!(metadata["id"], doc_id);
println!("Metadata retrieval successful");
},
ToolResult::Error(e) => panic!("Metadata retrieval should work: {}", e),
}
// Test word counting
let word_count_result = tool_result(&provider, "get_word_count", json!({
"document_id": doc_id
})).await;
match word_count_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let stats = &value["statistics"];
assert!(stats["words"].as_u64().unwrap() > 0);
println!("Word count successful: {} words", stats["words"]);
},
ToolResult::Error(e) => panic!("Word count should work: {}", e),
}
// Test document listing
let list_result = tool_result(&provider, "list_documents", json!({})).await;
match list_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
println!("Document listing successful");
},
ToolResult::Error(e) => panic!("Document listing should work: {}", e),
}
// Test that conversion operations are blocked (not in whitelist)
let pdf_result = tool_result(&provider, "convert_to_pdf", json!({
"document_id": doc_id,
"output_path": "/tmp/test.pdf"
})).await;
match pdf_result {
ToolResult::Success(value) => {
assert!(!value.get("success").unwrap_or(&json!(true)).as_bool().unwrap());
},
ToolResult::Error(e) => {
assert!(e.contains("Security check failed") || e.contains("Command not allowed"));
println!("PDF conversion correctly blocked: {}", e);
}
}
println!("Security-restricted workflow test completed successfully");
Ok(())
}
/// Test error recovery workflow
#[tokio::test]
async fn test_error_recovery_workflow() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let provider = DocxToolsProvider::with_base_dir(temp_dir.path());
// Test recovery from invalid document ID
let invalid_ops = vec![
("extract_text", json!({"document_id": "nonexistent-123"})),
("add_paragraph", json!({"document_id": "fake-456", "text": "test"})),
("get_metadata", json!({"document_id": "invalid-789"})),
("get_word_count", json!({"document_id": "missing-000"})),
];
for (operation, args) in invalid_ops {
let result = tool_result(&provider, operation, args).await;
match result {
ToolResult::Success(value) => {
assert!(!value.get("success").unwrap_or(&json!(true)).as_bool().unwrap());
println!("{} correctly handled invalid document ID (structured)", operation);
},
ToolResult::Error(e) => {
// Any error is acceptable for invalid IDs across operations
println!("{} correctly returned error for invalid document: {}", operation, e);
}
}
}
// Test recovery from invalid arguments
let invalid_arg_ops = vec![
("add_heading", json!({"document_id": "test", "level": 10})), // Invalid level
("add_paragraph", json!({"text": "missing document_id"})), // Missing required field
("add_table", json!({"document_id": "test", "rows": "not_an_array"})), // Wrong type
];
for (operation, args) in invalid_arg_ops {
let result = tool_result(&provider, operation, args).await;
match result {
ToolResult::Success(value) => {
assert!(!value.get("success").unwrap_or(&json!(true)).as_bool().unwrap());
println!("{} handled invalid arguments gracefully", operation);
},
ToolResult::Error(e) => {
println!("{} returned error for invalid arguments: {}", operation, e);
}
}
}
// Test successful operation after errors
let create_result = tool_result(&provider, "create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
value["document_id"].as_str().unwrap().to_string()
},
ToolResult::Error(e) => panic!("Should be able to create document after errors: {}", e),
};
// Verify normal operations work after handling errors
let paragraph_result = tool_result(&provider, "add_paragraph", json!({
"document_id": doc_id,
"text": "This should work after error recovery"
})).await;
match paragraph_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
println!("Normal operations work after error handling");
},
ToolResult::Error(e) => panic!("Normal operation should work after errors: {}", e),
}
// Test that the document has the expected content
let extract_result = tool_result(&provider, "extract_text", json!({
"document_id": doc_id
})).await;
match extract_result {
ToolResult::Success(value) => {
let text = value["text"].as_str().unwrap();
assert!(text.contains("This should work after error recovery"));
println!("Error recovery workflow completed successfully");
},
ToolResult::Error(e) => panic!("Text extraction failed: {}", e),
}
Ok(())
}
-457
View File
@@ -1,457 +0,0 @@
//! Test fixtures and helper data for the docx-mcp test suite
use anyhow::Result;
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
use serde_json::{json, Value};
use std::collections::HashMap;
use tempfile::TempDir;
pub mod sample_documents;
pub mod test_data;
/// Common test fixture for creating a handler with a temporary directory
pub fn create_test_handler() -> (DocxHandler, TempDir) {
let temp_dir = TempDir::new().unwrap();
let handler = DocxHandler::new().unwrap();
(handler, temp_dir)
}
/// Create a handler with a document containing basic content
pub fn create_handler_with_document() -> (DocxHandler, String, TempDir) {
let (mut handler, temp_dir) = create_test_handler();
let doc_id = handler.create_document().unwrap();
(handler, doc_id, temp_dir)
}
/// Standard document styles for testing
pub struct TestStyles;
impl TestStyles {
pub fn basic() -> DocxStyle {
DocxStyle {
font_family: Some("Calibri".to_string()),
font_size: Some(11),
bold: Some(false),
italic: Some(false),
underline: Some(false),
color: Some("#000000".to_string()),
alignment: Some("left".to_string()),
line_spacing: Some(1.15),
}
}
pub fn heading() -> DocxStyle {
DocxStyle {
font_family: Some("Calibri".to_string()),
font_size: Some(16),
bold: Some(true),
italic: Some(false),
underline: Some(false),
color: Some("#1f4e79".to_string()),
alignment: Some("left".to_string()),
line_spacing: Some(1.15),
}
}
pub fn emphasis() -> DocxStyle {
DocxStyle {
font_family: Some("Calibri".to_string()),
font_size: Some(11),
bold: Some(true),
italic: Some(true),
underline: Some(false),
color: Some("#c55a11".to_string()),
alignment: Some("left".to_string()),
line_spacing: Some(1.15),
}
}
pub fn centered() -> DocxStyle {
DocxStyle {
font_family: Some("Calibri".to_string()),
font_size: Some(11),
bold: Some(false),
italic: Some(false),
underline: Some(false),
color: Some("#000000".to_string()),
alignment: Some("center".to_string()),
line_spacing: Some(1.15),
}
}
}
/// Standard table data for testing
pub struct TestTables;
impl TestTables {
pub fn simple_2x2() -> TableData {
TableData {
rows: vec![
vec!["Row 1 Col 1".to_string(), "Row 1 Col 2".to_string()],
vec!["Row 2 Col 1".to_string(), "Row 2 Col 2".to_string()],
],
headers: None,
border_style: Some("single".to_string()),
}
}
pub fn with_headers() -> TableData {
TableData {
rows: vec![
vec!["Name".to_string(), "Age".to_string(), "City".to_string()],
vec!["John".to_string(), "30".to_string(), "New York".to_string()],
vec!["Jane".to_string(), "25".to_string(), "Los Angeles".to_string()],
vec!["Bob".to_string(), "35".to_string(), "Chicago".to_string()],
],
headers: Some(vec!["Name".to_string(), "Age".to_string(), "City".to_string()]),
border_style: Some("single".to_string()),
}
}
pub fn financial_data() -> TableData {
TableData {
rows: vec![
vec!["Quarter".to_string(), "Revenue".to_string(), "Profit".to_string(), "Growth".to_string()],
vec!["Q1 2024".to_string(), "$1.2M".to_string(), "$240K".to_string(), "15%".to_string()],
vec!["Q2 2024".to_string(), "$1.4M".to_string(), "$290K".to_string(), "18%".to_string()],
vec!["Q3 2024".to_string(), "$1.6M".to_string(), "$340K".to_string(), "22%".to_string()],
vec!["Q4 2024".to_string(), "$1.8M".to_string(), "$380K".to_string(), "25%".to_string()],
],
headers: Some(vec!["Quarter".to_string(), "Revenue".to_string(), "Profit".to_string(), "Growth".to_string()]),
border_style: Some("single".to_string()),
}
}
pub fn large_table(rows: usize, cols: usize) -> TableData {
let mut table_rows = Vec::new();
// Header row
let header_row: Vec<String> = (0..cols)
.map(|i| format!("Column {}", i + 1))
.collect();
table_rows.push(header_row.clone());
// Data rows
for row in 0..rows {
let data_row: Vec<String> = (0..cols)
.map(|col| format!("R{}C{}", row + 1, col + 1))
.collect();
table_rows.push(data_row);
}
TableData {
rows: table_rows,
headers: Some(header_row),
border_style: Some("single".to_string()),
}
}
}
/// Standard list data for testing
pub struct TestLists;
impl TestLists {
pub fn simple_bullets() -> Vec<String> {
vec![
"First bullet point".to_string(),
"Second bullet point".to_string(),
"Third bullet point".to_string(),
]
}
pub fn numbered_steps() -> Vec<String> {
vec![
"Open the application".to_string(),
"Navigate to the settings menu".to_string(),
"Select the desired configuration".to_string(),
"Save your changes".to_string(),
"Restart the application".to_string(),
]
}
pub fn features_list() -> Vec<String> {
vec![
"Advanced document editing capabilities".to_string(),
"Real-time collaboration tools".to_string(),
"Cloud synchronization".to_string(),
"Version control and history tracking".to_string(),
"Export to multiple formats (PDF, HTML, Markdown)".to_string(),
"Template library with professional designs".to_string(),
"Advanced formatting and styling options".to_string(),
]
}
pub fn technical_requirements() -> Vec<String> {
vec![
"Rust 1.70 or higher".to_string(),
"Memory: 2GB RAM minimum, 4GB recommended".to_string(),
"Storage: 500MB available space".to_string(),
"Network: Internet connection for cloud features".to_string(),
"OS: Windows 10, macOS 10.15, or Linux (Ubuntu 20.04+)".to_string(),
]
}
pub fn large_list(item_count: usize) -> Vec<String> {
(1..=item_count)
.map(|i| format!("List item number {} with descriptive content", i))
.collect()
}
}
/// Sample text content for testing
pub struct TestContent;
impl TestContent {
pub fn lorem_ipsum() -> &'static str {
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
}
pub fn technical_paragraph() -> &'static str {
"This application leverages cutting-edge Rust technology to provide high-performance document processing capabilities. The architecture is built on modern asynchronous programming patterns, ensuring efficient resource utilization and scalability. Key features include memory-safe operations, zero-cost abstractions, and excellent concurrent processing performance."
}
pub fn business_paragraph() -> &'static str {
"Our comprehensive business solution addresses the evolving needs of modern enterprises through innovative technology and streamlined workflows. With a focus on productivity enhancement and cost reduction, this platform delivers measurable value across multiple departments and use cases. The solution integrates seamlessly with existing infrastructure while providing robust security and compliance features."
}
pub fn multilingual_content() -> Vec<(&'static str, &'static str)> {
vec![
("English", "The quick brown fox jumps over the lazy dog."),
("Spanish", "El zorro marrón rápido salta sobre el perro perezoso."),
("French", "Le renard brun rapide saute par-dessus le chien paresseux."),
("German", "Der schnelle braune Fuchs springt über den faulen Hund."),
("Italian", "La volpe marrone veloce salta sopra il cane pigro."),
("Portuguese", "A raposa marrom rápida pula sobre o cão preguiçoso."),
("Japanese", "素早い茶色のキツネは怠惰な犬を飛び越える。"),
("Chinese", "敏捷的棕色狐狸跳过懒狗。"),
("Korean", "빠른 갈색 여우가 게으른 개를 뛰어넘는다."),
("Russian", "Быстрая коричневая лиса прыгает через ленивую собаку."),
]
}
pub fn special_characters() -> &'static str {
"Special characters test: àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ ĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚě"
}
pub fn symbols_and_math() -> &'static str {
"Mathematical symbols: ∑ ∏ ∫ √ ≤ ≥ ≠ ± ∞ ∂ ∇ Ω α β γ δ ε θ λ μ π σ φ ψ ω"
}
pub fn long_paragraph(sentence_count: usize) -> String {
let sentences = vec![
"This is a comprehensive test of document processing capabilities.",
"The system handles various types of content efficiently and accurately.",
"Performance optimization ensures smooth operation even with large documents.",
"Advanced formatting features provide professional document appearance.",
"Error handling mechanisms maintain system stability under all conditions.",
"Security features protect sensitive information throughout the process.",
"Integration capabilities allow seamless workflow with existing systems.",
"User-friendly interfaces make complex operations simple and intuitive.",
"Scalable architecture supports growing business requirements.",
"Continuous improvements ensure the solution remains cutting-edge.",
];
let mut result = String::new();
for i in 0..sentence_count {
let sentence = sentences[i % sentences.len()];
result.push_str(sentence);
if i < sentence_count - 1 {
result.push(' ');
}
}
result
}
}
/// MCP tool call arguments for testing
pub struct TestMcpArgs;
impl TestMcpArgs {
pub fn create_document() -> Value {
json!({})
}
pub fn add_paragraph(doc_id: &str, text: &str, style: Option<DocxStyle>) -> Value {
let mut args = json!({
"document_id": doc_id,
"text": text
});
if let Some(s) = style {
args["style"] = json!({
"font_family": s.font_family,
"font_size": s.font_size,
"bold": s.bold,
"italic": s.italic,
"underline": s.underline,
"color": s.color,
"alignment": s.alignment,
"line_spacing": s.line_spacing
});
}
args
}
pub fn add_heading(doc_id: &str, text: &str, level: usize) -> Value {
json!({
"document_id": doc_id,
"text": text,
"level": level
})
}
pub fn add_table(doc_id: &str, table_data: &TableData) -> Value {
json!({
"document_id": doc_id,
"rows": table_data.rows
})
}
pub fn add_list(doc_id: &str, items: &[String], ordered: bool) -> Value {
json!({
"document_id": doc_id,
"items": items,
"ordered": ordered
})
}
pub fn extract_text(doc_id: &str) -> Value {
json!({
"document_id": doc_id
})
}
pub fn search_text(doc_id: &str, search_term: &str, case_sensitive: bool) -> Value {
json!({
"document_id": doc_id,
"search_term": search_term,
"case_sensitive": case_sensitive
})
}
pub fn get_metadata(doc_id: &str) -> Value {
json!({
"document_id": doc_id
})
}
pub fn convert_to_pdf(doc_id: &str, output_path: &str) -> Value {
json!({
"document_id": doc_id,
"output_path": output_path
})
}
pub fn save_document(doc_id: &str, output_path: &str) -> Value {
json!({
"document_id": doc_id,
"output_path": output_path
})
}
}
/// Performance test data generators
pub struct PerformanceData;
impl PerformanceData {
pub fn create_large_document(handler: &mut DocxHandler, paragraph_count: usize) -> Result<String> {
let doc_id = handler.create_document()?;
handler.add_heading(&doc_id, "Performance Test Document", 1)?;
for i in 0..paragraph_count {
if i % 50 == 0 && i > 0 {
handler.add_heading(&doc_id, &format!("Section {}", i / 50), 2)?;
}
let content = format!(
"This is paragraph {} in our performance test document. It contains substantial text content to simulate real-world usage patterns and test system performance under realistic load conditions. The paragraph includes various punctuation marks, numbers like {}, and other elements that affect processing performance.",
i + 1, (i + 1) * 7
);
handler.add_paragraph(&doc_id, &content, None)?;
// Add tables periodically
if i % 100 == 99 {
let table_data = TestTables::simple_2x2();
handler.add_table(&doc_id, table_data)?;
}
}
Ok(doc_id)
}
pub fn create_complex_document(handler: &mut DocxHandler) -> Result<String> {
let doc_id = handler.create_document()?;
// Add comprehensive content with all features
handler.add_heading(&doc_id, "Complex Document Test", 1)?;
handler.set_header(&doc_id, "Complex Document Header")?;
handler.set_footer(&doc_id, "Complex Document Footer")?;
handler.add_paragraph(&doc_id, TestContent::business_paragraph(), Some(TestStyles::basic()))?;
handler.add_heading(&doc_id, "Technical Details", 2)?;
handler.add_paragraph(&doc_id, TestContent::technical_paragraph(), None)?;
let features_list = TestLists::features_list();
handler.add_list(&doc_id, features_list, false)?;
handler.add_heading(&doc_id, "Financial Overview", 2)?;
let financial_table = TestTables::financial_data();
handler.add_table(&doc_id, financial_table)?;
handler.add_page_break(&doc_id)?;
handler.add_heading(&doc_id, "Multilingual Content", 2)?;
for (language, text) in TestContent::multilingual_content() {
handler.add_paragraph(&doc_id, &format!("{}: {}", language, text), None)?;
}
handler.add_heading(&doc_id, "Special Characters", 2)?;
handler.add_paragraph(&doc_id, TestContent::special_characters(), None)?;
handler.add_paragraph(&doc_id, TestContent::symbols_and_math(), None)?;
Ok(doc_id)
}
}
/// Error testing utilities
pub struct ErrorTestCases;
impl ErrorTestCases {
pub fn invalid_document_ids() -> Vec<&'static str> {
vec![
"nonexistent-123",
"fake-document-id",
"invalid-uuid",
"",
" ",
"null",
"undefined",
]
}
pub fn invalid_mcp_calls() -> Vec<(&'static str, Value)> {
vec![
("add_paragraph", json!({"text": "missing document_id"})),
("add_heading", json!({"document_id": "test", "level": 10})),
("add_table", json!({"document_id": "test", "rows": "not_an_array"})),
("add_list", json!({"document_id": "test", "items": 123})),
("search_text", json!({"document_id": "test"})), // Missing search_term
("convert_to_pdf", json!({"document_id": "test"})), // Missing output_path
]
}
pub fn security_blocked_operations() -> Vec<(&'static str, Value)> {
vec![
("create_document", json!({})),
("add_paragraph", json!({"document_id": "test", "text": "blocked"})),
("save_document", json!({"document_id": "test", "output_path": "/tmp/test.docx"})),
("convert_to_pdf", json!({"document_id": "test", "output_path": "/tmp/test.pdf"})),
("find_and_replace", json!({"document_id": "test", "find_text": "a", "replace_text": "b"})),
]
}
}
-530
View File
@@ -1,530 +0,0 @@
//! Sample document templates and content for testing
use anyhow::Result;
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
use super::{TestStyles, TestTables, TestLists, TestContent};
/// Creates a business letter document for testing
pub fn create_business_letter(handler: &mut DocxHandler) -> Result<String> {
let doc_id = handler.create_document()?;
// Header
handler.set_header(&doc_id, "ACME Corporation | 123 Business St, City, State 12345")?;
// Date
handler.add_paragraph(&doc_id, "December 15, 2024", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "", None)?; // Empty line
// Recipient
handler.add_paragraph(&doc_id, "Ms. Jane Smith", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "Director of Operations", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "XYZ Company", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "456 Corporate Ave", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "Business City, State 67890", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "", None)?; // Empty line
// Subject
handler.add_paragraph(&doc_id, "RE: Partnership Proposal", Some(TestStyles::emphasis()))?;
handler.add_paragraph(&doc_id, "", None)?; // Empty line
// Salutation
handler.add_paragraph(&doc_id, "Dear Ms. Smith,", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "", None)?; // Empty line
// Body paragraphs
handler.add_paragraph(&doc_id,
"I am writing to propose a strategic partnership between ACME Corporation and XYZ Company that would benefit both organizations significantly. Our companies share similar values and complementary strengths that could create substantial value for our respective customers.",
Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id,
"ACME Corporation has been a leader in technology solutions for over 15 years, with a strong track record of innovation and customer satisfaction. We believe that combining our technical expertise with your operational excellence would create a powerful synergy in the marketplace.",
Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id,
"The proposed partnership would include joint product development, shared marketing initiatives, and coordinated customer support efforts. We estimate this collaboration could increase revenue for both companies by 25% within the first year.",
Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id,
"I would welcome the opportunity to discuss this proposal in more detail at your convenience. Please let me know when you might be available for a meeting or conference call.",
Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "", None)?; // Empty line
// Closing
handler.add_paragraph(&doc_id, "Sincerely,", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "", None)?; // Space for signature
handler.add_paragraph(&doc_id, "", None)?; // Space for signature
handler.add_paragraph(&doc_id, "John Doe", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "Chief Executive Officer", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "ACME Corporation", Some(TestStyles::basic()))?;
// Footer
handler.set_footer(&doc_id, "ACME Corporation - Confidential and Proprietary")?;
Ok(doc_id)
}
/// Creates a technical report document for testing
pub fn create_technical_report(handler: &mut DocxHandler) -> Result<String> {
let doc_id = handler.create_document()?;
// Title page
handler.add_paragraph(&doc_id, "", None)?; // Empty line for spacing
handler.add_paragraph(&doc_id, "", None)?;
handler.add_paragraph(&doc_id, "", None)?;
handler.add_heading(&doc_id, "System Performance Analysis Report", 1)?;
handler.add_paragraph(&doc_id, "", None)?;
handler.add_paragraph(&doc_id, "Quarterly Assessment - Q4 2024", Some(TestStyles::centered()))?;
handler.add_paragraph(&doc_id, "", None)?;
handler.add_paragraph(&doc_id, "Prepared by: Technical Team", Some(TestStyles::centered()))?;
handler.add_paragraph(&doc_id, "Date: December 15, 2024", Some(TestStyles::centered()))?;
handler.add_page_break(&doc_id)?;
// Executive Summary
handler.add_heading(&doc_id, "Executive Summary", 1)?;
handler.add_paragraph(&doc_id,
"This report provides a comprehensive analysis of system performance metrics for Q4 2024. Key findings include significant improvements in response times, enhanced security measures, and successful implementation of new monitoring capabilities.",
Some(TestStyles::basic()))?;
let summary_points = vec![
"Average response time improved by 35%".to_string(),
"System uptime achieved 99.97%".to_string(),
"Security incidents reduced by 60%".to_string(),
"User satisfaction increased to 94%".to_string(),
];
handler.add_list(&doc_id, summary_points, false)?;
// Performance Metrics
handler.add_heading(&doc_id, "Performance Metrics", 1)?;
handler.add_heading(&doc_id, "Response Time Analysis", 2)?;
handler.add_paragraph(&doc_id,
"Response time measurements were collected continuously throughout Q4 2024. The data shows consistent improvement across all service endpoints.",
Some(TestStyles::basic()))?;
let response_time_data = TableData {
rows: vec![
vec!["Service".to_string(), "Q3 2024 (ms)".to_string(), "Q4 2024 (ms)".to_string(), "Improvement".to_string()],
vec!["Authentication".to_string(), "245".to_string(), "158".to_string(), "35.5%".to_string()],
vec!["Database Query".to_string(), "892".to_string(), "623".to_string(), "30.2%".to_string()],
vec!["File Processing".to_string(), "1,240".to_string(), "789".to_string(), "36.4%".to_string()],
vec!["Report Generation".to_string(), "3,450".to_string(), "2,180".to_string(), "36.8%".to_string()],
],
headers: Some(vec!["Service".to_string(), "Q3 2024 (ms)".to_string(), "Q4 2024 (ms)".to_string(), "Improvement".to_string()]),
border_style: Some("single".to_string()),
col_widths: None,
merges: None,
cell_shading: None,
};
handler.add_table(&doc_id, response_time_data)?;
handler.add_heading(&doc_id, "System Reliability", 2)?;
handler.add_paragraph(&doc_id,
"System reliability metrics demonstrate exceptional stability and availability throughout the quarter.",
Some(TestStyles::basic()))?;
let reliability_data = TableData {
rows: vec![
vec!["Metric".to_string(), "Target".to_string(), "Actual".to_string(), "Status".to_string()],
vec!["Uptime".to_string(), "99.9%".to_string(), "99.97%".to_string(), "✓ Exceeded".to_string()],
vec!["MTBF (hours)".to_string(), "720".to_string(), "892".to_string(), "✓ Exceeded".to_string()],
vec!["Recovery Time (min)".to_string(), "15".to_string(), "8.5".to_string(), "✓ Exceeded".to_string()],
],
headers: Some(vec!["Metric".to_string(), "Target".to_string(), "Actual".to_string(), "Status".to_string()]),
border_style: Some("single".to_string()),
col_widths: None,
merges: None,
cell_shading: None,
};
handler.add_table(&doc_id, reliability_data)?;
// Security Analysis
handler.add_heading(&doc_id, "Security Analysis", 1)?;
handler.add_paragraph(&doc_id,
"Security monitoring and incident response capabilities were significantly enhanced during Q4 2024.",
Some(TestStyles::basic()))?;
let security_improvements = vec![
"Implemented advanced threat detection algorithms".to_string(),
"Enhanced encryption protocols for data transmission".to_string(),
"Deployed automated incident response systems".to_string(),
"Conducted comprehensive security audits".to_string(),
"Updated access control mechanisms".to_string(),
];
handler.add_list(&doc_id, security_improvements, true)?;
// Recommendations
handler.add_heading(&doc_id, "Recommendations", 1)?;
handler.add_paragraph(&doc_id,
"Based on the analysis conducted, the following recommendations are proposed for Q1 2025:",
Some(TestStyles::basic()))?;
let recommendations = vec![
"Continue performance optimization initiatives".to_string(),
"Expand monitoring coverage to include new services".to_string(),
"Implement predictive analytics for proactive maintenance".to_string(),
"Enhance disaster recovery procedures".to_string(),
"Invest in additional security training for staff".to_string(),
];
handler.add_list(&doc_id, recommendations, true)?;
// Footer
handler.set_footer(&doc_id, "Technical Report Q4 2024 - Confidential")?;
Ok(doc_id)
}
/// Creates a meeting minutes document for testing
pub fn create_meeting_minutes(handler: &mut DocxHandler) -> Result<String> {
let doc_id = handler.create_document()?;
// Header
handler.add_heading(&doc_id, "Project Steering Committee Meeting Minutes", 1)?;
handler.add_paragraph(&doc_id, "", None)?;
// Meeting details
let meeting_details = TableData {
rows: vec![
vec!["Date:".to_string(), "December 15, 2024".to_string()],
vec!["Time:".to_string(), "2:00 PM - 3:30 PM PST".to_string()],
vec!["Location:".to_string(), "Conference Room A / Virtual".to_string()],
vec!["Chair:".to_string(), "Sarah Johnson".to_string()],
vec!["Secretary:".to_string(), "Mike Chen".to_string()],
],
headers: None,
border_style: Some("single".to_string()),
col_widths: None,
merges: None,
cell_shading: None,
};
handler.add_table(&doc_id, meeting_details)?;
// Attendees
handler.add_heading(&doc_id, "Attendees", 2)?;
let attendees = vec![
"Sarah Johnson (Chair) - Project Director".to_string(),
"Mike Chen (Secretary) - Technical Lead".to_string(),
"Lisa Wang - Product Manager".to_string(),
"David Rodriguez - Engineering Manager".to_string(),
"Jennifer Kim - QA Manager".to_string(),
"Alex Thompson - DevOps Lead".to_string(),
];
handler.add_list(&doc_id, attendees, false)?;
// Agenda Items
handler.add_heading(&doc_id, "Agenda Items Discussed", 2)?;
handler.add_heading(&doc_id, "1. Project Status Update", 3)?;
handler.add_paragraph(&doc_id,
"Mike Chen presented the current project status, highlighting that development is 85% complete and on schedule for the January 31st deadline.",
Some(TestStyles::basic()))?;
let status_highlights = vec![
"Core functionality implementation: 100% complete".to_string(),
"User interface development: 90% complete".to_string(),
"Testing and QA: 70% complete".to_string(),
"Documentation: 60% complete".to_string(),
];
handler.add_list(&doc_id, status_highlights, false)?;
handler.add_heading(&doc_id, "2. Budget Review", 3)?;
handler.add_paragraph(&doc_id,
"Lisa Wang reported that the project is currently 5% under budget with strong cost controls in place.",
Some(TestStyles::basic()))?;
let budget_data = TableData {
rows: vec![
vec!["Category".to_string(), "Budgeted".to_string(), "Actual".to_string(), "Remaining".to_string()],
vec!["Development".to_string(), "$180,000".to_string(), "$168,000".to_string(), "$12,000".to_string()],
vec!["Testing".to_string(), "$45,000".to_string(), "$38,000".to_string(), "$7,000".to_string()],
vec!["Infrastructure".to_string(), "$30,000".to_string(), "$28,000".to_string(), "$2,000".to_string()],
vec!["Total".to_string(), "$255,000".to_string(), "$234,000".to_string(), "$21,000".to_string()],
],
headers: Some(vec!["Category".to_string(), "Budgeted".to_string(), "Actual".to_string(), "Remaining".to_string()]),
border_style: Some("single".to_string()),
col_widths: None,
merges: None,
cell_shading: None,
};
handler.add_table(&doc_id, budget_data)?;
handler.add_heading(&doc_id, "3. Risk Assessment", 3)?;
handler.add_paragraph(&doc_id,
"David Rodriguez presented the updated risk register with mitigation strategies for identified risks.",
Some(TestStyles::basic()))?;
let risks = vec![
"Third-party API integration delays - Medium risk, mitigation plan in place".to_string(),
"Resource availability during holidays - Low risk, backup resources identified".to_string(),
"Performance requirements validation - Medium risk, load testing scheduled".to_string(),
];
handler.add_list(&doc_id, risks, false)?;
// Action Items
handler.add_heading(&doc_id, "Action Items", 2)?;
let action_items_data = TableData {
rows: vec![
vec!["Action Item".to_string(), "Owner".to_string(), "Due Date".to_string(), "Status".to_string()],
vec!["Complete load testing scenarios".to_string(), "Jennifer Kim".to_string(), "Dec 22, 2024".to_string(), "In Progress".to_string()],
vec!["Finalize API integration testing".to_string(), "Mike Chen".to_string(), "Dec 20, 2024".to_string(), "Not Started".to_string()],
vec!["Update project documentation".to_string(), "Lisa Wang".to_string(), "Jan 10, 2025".to_string(), "Not Started".to_string()],
vec!["Prepare deployment checklist".to_string(), "Alex Thompson".to_string(), "Jan 15, 2025".to_string(), "Not Started".to_string()],
],
headers: Some(vec!["Action Item".to_string(), "Owner".to_string(), "Due Date".to_string(), "Status".to_string()]),
border_style: Some("single".to_string()),
col_widths: None,
merges: None,
cell_shading: None,
};
handler.add_table(&doc_id, action_items_data)?;
// Next Meeting
handler.add_heading(&doc_id, "Next Meeting", 2)?;
handler.add_paragraph(&doc_id,
"The next steering committee meeting is scheduled for January 5, 2025, at 2:00 PM PST in Conference Room A.",
Some(TestStyles::basic()))?;
// Footer
handler.set_footer(&doc_id, "Project Steering Committee - Meeting Minutes")?;
Ok(doc_id)
}
/// Creates a product specification document for testing
pub fn create_product_spec(handler: &mut DocxHandler) -> Result<String> {
let doc_id = handler.create_document()?;
// Title page
handler.add_paragraph(&doc_id, "", None)?;
handler.add_paragraph(&doc_id, "", None)?;
handler.add_heading(&doc_id, "Product Requirements Specification", 1)?;
handler.add_paragraph(&doc_id, "", None)?;
handler.add_paragraph(&doc_id, "Document Management System v2.0", Some(TestStyles::centered()))?;
handler.add_paragraph(&doc_id, "", None)?;
handler.add_paragraph(&doc_id, "Version 1.0", Some(TestStyles::centered()))?;
handler.add_paragraph(&doc_id, "December 15, 2024", Some(TestStyles::centered()))?;
handler.add_page_break(&doc_id)?;
// Table of Contents (simplified)
handler.add_heading(&doc_id, "Table of Contents", 1)?;
let toc_items = vec![
"1. Introduction".to_string(),
"2. System Overview".to_string(),
"3. Functional Requirements".to_string(),
"4. Non-Functional Requirements".to_string(),
"5. User Interface Requirements".to_string(),
"6. System Architecture".to_string(),
"7. Security Requirements".to_string(),
];
handler.add_list(&doc_id, toc_items, true)?;
// Introduction
handler.add_heading(&doc_id, "1. Introduction", 1)?;
handler.add_heading(&doc_id, "1.1 Purpose", 2)?;
handler.add_paragraph(&doc_id,
"This document specifies the requirements for the Document Management System version 2.0. The system is designed to provide comprehensive document storage, retrieval, and collaboration capabilities for enterprise users.",
Some(TestStyles::basic()))?;
handler.add_heading(&doc_id, "1.2 Scope", 2)?;
handler.add_paragraph(&doc_id,
"The Document Management System will support multiple file formats, version control, user collaboration, and advanced search capabilities. The system will be deployed as a web-based application with mobile support.",
Some(TestStyles::basic()))?;
// System Overview
handler.add_heading(&doc_id, "2. System Overview", 1)?;
handler.add_paragraph(&doc_id,
"The Document Management System consists of several integrated components working together to provide a seamless document management experience.",
Some(TestStyles::basic()))?;
let system_components = vec![
"Document Storage Engine".to_string(),
"Version Control System".to_string(),
"Search and Indexing Service".to_string(),
"User Authentication and Authorization".to_string(),
"Collaboration Tools".to_string(),
"Reporting and Analytics".to_string(),
];
handler.add_list(&doc_id, system_components, false)?;
// Functional Requirements
handler.add_heading(&doc_id, "3. Functional Requirements", 1)?;
handler.add_heading(&doc_id, "3.1 Document Upload and Storage", 2)?;
let upload_requirements = vec![
"FR-001: System shall support upload of files up to 100MB in size".to_string(),
"FR-002: System shall support common file formats (PDF, DOCX, XLSX, PPTX, TXT)".to_string(),
"FR-003: System shall automatically generate file metadata upon upload".to_string(),
"FR-004: System shall provide drag-and-drop upload functionality".to_string(),
];
handler.add_list(&doc_id, upload_requirements, false)?;
handler.add_heading(&doc_id, "3.2 Search and Retrieval", 2)?;
let search_requirements = vec![
"FR-005: System shall provide full-text search capabilities".to_string(),
"FR-006: System shall support advanced search with multiple criteria".to_string(),
"FR-007: System shall provide search result ranking and relevance scoring".to_string(),
"FR-008: System shall support search within specific document types".to_string(),
];
handler.add_list(&doc_id, search_requirements, false)?;
// Non-Functional Requirements
handler.add_heading(&doc_id, "4. Non-Functional Requirements", 1)?;
let nfr_data = TableData {
rows: vec![
vec!["Requirement".to_string(), "Specification".to_string(), "Priority".to_string()],
vec!["Performance".to_string(), "Page load time < 3 seconds".to_string(), "High".to_string()],
vec!["Scalability".to_string(), "Support 1000+ concurrent users".to_string(), "High".to_string()],
vec!["Availability".to_string(), "99.9% uptime".to_string(), "High".to_string()],
vec!["Security".to_string(), "Role-based access control".to_string(), "Critical".to_string()],
vec!["Usability".to_string(), "Intuitive interface, minimal training".to_string(), "Medium".to_string()],
],
headers: Some(vec!["Requirement".to_string(), "Specification".to_string(), "Priority".to_string()]),
border_style: Some("single".to_string()),
col_widths: None,
merges: None,
cell_shading: None,
};
handler.add_table(&doc_id, nfr_data)?;
// Security Requirements
handler.add_heading(&doc_id, "7. Security Requirements", 1)?;
handler.add_paragraph(&doc_id,
"Security is paramount for the Document Management System. The following security measures must be implemented:",
Some(TestStyles::basic()))?;
let security_requirements = vec![
"SEC-001: All data transmission must use HTTPS/TLS 1.3".to_string(),
"SEC-002: User passwords must meet complexity requirements".to_string(),
"SEC-003: System must support multi-factor authentication".to_string(),
"SEC-004: All user actions must be logged for audit purposes".to_string(),
"SEC-005: Document access must be controlled by user permissions".to_string(),
"SEC-006: System must support data encryption at rest".to_string(),
];
handler.add_list(&doc_id, security_requirements, true)?;
// Footer
handler.set_footer(&doc_id, "Product Requirements Specification v1.0 - Confidential")?;
Ok(doc_id)
}
/// Creates a test document with international content
pub fn create_multilingual_document(handler: &mut DocxHandler) -> Result<String> {
let doc_id = handler.create_document()?;
handler.add_heading(&doc_id, "Multilingual Content Test Document", 1)?;
handler.add_paragraph(&doc_id,
"This document contains text in multiple languages to test internationalization and Unicode support.",
Some(TestStyles::basic()))?;
for (language, text) in TestContent::multilingual_content() {
handler.add_heading(&doc_id, language, 2)?;
handler.add_paragraph(&doc_id, text, Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "", None)?; // Empty line
}
handler.add_heading(&doc_id, "Special Characters and Symbols", 2)?;
handler.add_paragraph(&doc_id, TestContent::special_characters(), Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, TestContent::symbols_and_math(), Some(TestStyles::basic()))?;
// Currency symbols
handler.add_paragraph(&doc_id, "Currency symbols: $ € £ ¥ ₹ ₽ ₩ ₪ ₫ ₱", Some(TestStyles::basic()))?;
// Emoji (if supported)
handler.add_paragraph(&doc_id, "Emoji test: 📄 📝 💼 🔒 🌍 ✅ ❌ ⚠️", Some(TestStyles::basic()))?;
Ok(doc_id)
}
/// Creates a document with complex formatting for testing
pub fn create_formatted_document(handler: &mut DocxHandler) -> Result<String> {
let doc_id = handler.create_document()?;
handler.add_heading(&doc_id, "Formatting Test Document", 1)?;
// Different paragraph styles
handler.add_paragraph(&doc_id, "This paragraph uses the default style.", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "This paragraph uses bold formatting.", Some(DocxStyle {
bold: Some(true),
..TestStyles::basic()
}))?;
handler.add_paragraph(&doc_id, "This paragraph uses italic formatting.", Some(DocxStyle {
italic: Some(true),
..TestStyles::basic()
}))?;
handler.add_paragraph(&doc_id, "This paragraph is centered.", Some(TestStyles::centered()))?;
handler.add_paragraph(&doc_id, "This paragraph uses emphasis styling.", Some(TestStyles::emphasis()))?;
// Different font sizes
handler.add_heading(&doc_id, "Font Size Tests", 2)?;
for size in [8, 10, 12, 14, 16, 18, 24] {
let style = DocxStyle {
font_size: Some(size),
..TestStyles::basic()
};
handler.add_paragraph(&doc_id, &format!("This text is {} point size.", size), Some(style))?;
}
// Color tests
handler.add_heading(&doc_id, "Color Tests", 2)?;
let colors = vec![
("#000000", "Black"),
("#FF0000", "Red"),
("#00FF00", "Green"),
("#0000FF", "Blue"),
("#FF00FF", "Magenta"),
("#00FFFF", "Cyan"),
("#800080", "Purple"),
];
for (color_code, color_name) in colors {
let style = DocxStyle {
color: Some(color_code.to_string()),
..TestStyles::basic()
};
handler.add_paragraph(&doc_id, &format!("This text is in {}", color_name), Some(style))?;
}
// Alignment tests
handler.add_heading(&doc_id, "Alignment Tests", 2)?;
let alignments = vec![
("left", "Left aligned text"),
("center", "Center aligned text"),
("right", "Right aligned text"),
("justify", "Justified text that should span the full width of the line when there is enough content to make it meaningful"),
];
for (alignment, text) in alignments {
let style = DocxStyle {
alignment: Some(alignment.to_string()),
..TestStyles::basic()
};
handler.add_paragraph(&doc_id, text, Some(style))?;
}
// Complex table with formatting
handler.add_heading(&doc_id, "Formatted Table", 2)?;
let formatted_table = TableData {
rows: vec![
vec!["Item".to_string(), "Price".to_string(), "Discount".to_string(), "Final Price".to_string()],
vec!["Widget A".to_string(), "$100.00".to_string(), "10%".to_string(), "$90.00".to_string()],
vec!["Widget B".to_string(), "$150.00".to_string(), "15%".to_string(), "$127.50".to_string()],
vec!["Widget C".to_string(), "$200.00".to_string(), "20%".to_string(), "$160.00".to_string()],
vec!["Total".to_string(), "$450.00".to_string(), "".to_string(), "$377.50".to_string()],
],
headers: Some(vec!["Item".to_string(), "Price".to_string(), "Discount".to_string(), "Final Price".to_string()]),
border_style: Some("single".to_string()),
col_widths: None,
merges: None,
cell_shading: None,
};
handler.add_table(&doc_id, formatted_table)?;
Ok(doc_id)
}
-392
View File
@@ -1,392 +0,0 @@
//! Test data generators and utilities
use serde_json::{json, Value};
use std::collections::HashMap;
/// Generates test data for various document types and scenarios
pub struct TestDataGenerator;
impl TestDataGenerator {
/// Generate test paragraphs with varying complexity
pub fn generate_paragraphs(count: usize, complexity: ParagraphComplexity) -> Vec<String> {
let base_sentences = match complexity {
ParagraphComplexity::Simple => vec![
"This is a simple sentence.",
"Another basic statement follows.",
"The text remains straightforward.",
"No complex structures here.",
"Plain language is used throughout.",
],
ParagraphComplexity::Medium => vec![
"This sentence demonstrates moderate complexity with additional clauses and descriptive elements.",
"Furthermore, the content includes various punctuation marks, numbers like 123, and technical terms.",
"The writing style incorporates both simple and compound sentence structures for variety.",
"Additionally, references to specific dates (December 15, 2024) and percentages (85%) are included.",
"These paragraphs simulate realistic document content found in business communications.",
],
ParagraphComplexity::Complex => vec![
"This comprehensive sentence exemplifies sophisticated linguistic structures, incorporating multiple subordinate clauses, technical terminology, and complex syntactical arrangements that challenge both human readers and automated processing systems.",
"Moreover, the content integrates diverse elements including numerical data (such as 42.7% improvement rates), temporal references (spanning Q3 2024 through Q1 2025), geographical locations (Silicon Valley, New York, London), and industry-specific jargon that reflects real-world document complexity.",
"The methodology employed in generating these test paragraphs considers various factors: readability indices, sentence length distribution, vocabulary diversity, and the inclusion of special characters (e.g., àáâãäå, €£¥, ∑∏∫) to ensure comprehensive testing coverage.",
"Consequently, these multi-faceted paragraphs serve as effective benchmarks for evaluating system performance under realistic conditions, while simultaneously providing sufficient content variation to identify potential edge cases and optimization opportunities.",
],
};
(0..count)
.map(|i| {
let sentence_count = match complexity {
ParagraphComplexity::Simple => 2 + (i % 3),
ParagraphComplexity::Medium => 3 + (i % 4),
ParagraphComplexity::Complex => 2 + (i % 3),
};
let mut paragraph = String::new();
for j in 0..sentence_count {
let sentence = &base_sentences[j % base_sentences.len()];
if j > 0 {
paragraph.push(' ');
}
paragraph.push_str(sentence);
}
paragraph
})
.collect()
}
/// Generate table data with specified dimensions and content type
pub fn generate_table_data(rows: usize, cols: usize, content_type: TableContentType) -> Vec<Vec<String>> {
let mut table_data = Vec::new();
// Generate header row
let headers: Vec<String> = (0..cols)
.map(|i| match content_type {
TableContentType::Generic => format!("Column {}", i + 1),
TableContentType::Financial => match i {
0 => "Period".to_string(),
1 => "Revenue".to_string(),
2 => "Expenses".to_string(),
3 => "Profit".to_string(),
_ => format!("Metric {}", i + 1),
},
TableContentType::Personnel => match i {
0 => "Name".to_string(),
1 => "Department".to_string(),
2 => "Role".to_string(),
3 => "Start Date".to_string(),
_ => format!("Field {}", i + 1),
},
TableContentType::Technical => match i {
0 => "Component".to_string(),
1 => "Version".to_string(),
2 => "Status".to_string(),
3 => "Last Updated".to_string(),
_ => format!("Attribute {}", i + 1),
},
})
.collect();
table_data.push(headers);
// Generate data rows
for row in 0..rows {
let row_data: Vec<String> = (0..cols)
.map(|col| match content_type {
TableContentType::Generic => format!("R{}C{}", row + 1, col + 1),
TableContentType::Financial => match col {
0 => format!("Q{} 2024", (row % 4) + 1),
1 => format!("${:.1}M", 100.0 + row as f64 * 12.5),
2 => format!("${:.1}M", 70.0 + row as f64 * 8.2),
3 => format!("${:.1}M", 30.0 + row as f64 * 4.3),
_ => format!("{:.1}%", 15.0 + row as f64 * 2.1),
},
TableContentType::Personnel => match col {
0 => format!("Employee {}", row + 1),
1 => ["Engineering", "Sales", "Marketing", "Operations"][(row % 4)].to_string(),
2 => ["Manager", "Developer", "Analyst", "Specialist"][(row % 4)].to_string(),
3 => format!("2024-{:02}-{:02}", ((row % 12) + 1), ((row % 28) + 1)),
_ => format!("Data {}", row + 1),
},
TableContentType::Technical => match col {
0 => format!("Component-{}", row + 1),
1 => format!("v{}.{}.{}", (row % 3) + 1, (row % 5), (row % 10)),
2 => ["Active", "Pending", "Deprecated", "Testing"][(row % 4)].to_string(),
3 => format!("2024-12-{:02}", ((row % 28) + 1)),
_ => format!("Value {}", row + 1),
},
})
.collect();
table_data.push(row_data);
}
table_data
}
/// Generate list items with specified count and category
pub fn generate_list_items(count: usize, category: ListCategory) -> Vec<String> {
let base_items = match category {
ListCategory::Tasks => vec![
"Complete project documentation",
"Review code changes and pull requests",
"Update system configuration files",
"Run comprehensive test suite",
"Deploy to staging environment",
"Conduct security audit",
"Optimize database performance",
"Update user interface components",
"Implement new feature requirements",
"Fix reported bugs and issues",
],
ListCategory::Features => vec![
"Advanced search and filtering capabilities",
"Real-time collaboration tools",
"Automated backup and recovery",
"Multi-language support",
"Mobile-responsive design",
"Integration with third-party services",
"Customizable dashboard and reports",
"Role-based access control",
"API for external integrations",
"Advanced analytics and insights",
],
ListCategory::Requirements => vec![
"System must support 1000+ concurrent users",
"Response time must be under 200ms for 95% of requests",
"Uptime must exceed 99.9% availability",
"Data must be encrypted both in transit and at rest",
"User interface must be accessible (WCAG 2.1 AA)",
"System must support multi-factor authentication",
"Backup processes must complete within 2 hours",
"Security patches must be applied within 24 hours",
"System must scale horizontally to handle peak loads",
"Audit logs must be maintained for minimum 7 years",
],
ListCategory::Benefits => vec![
"Increased operational efficiency by 35%",
"Reduced manual processing time by 60%",
"Improved data accuracy and consistency",
"Enhanced security and compliance posture",
"Better user experience and satisfaction",
"Lower total cost of ownership",
"Faster time-to-market for new features",
"Improved scalability and performance",
"Better decision-making through analytics",
"Reduced maintenance and support costs",
],
};
(0..count)
.map(|i| {
let base_item = &base_items[i % base_items.len()];
if count > base_items.len() {
format!("{} (item {})", base_item, i + 1)
} else {
base_item.clone()
}
})
.collect()
}
/// Generate realistic business data for testing
pub fn generate_business_data() -> BusinessDataSet {
BusinessDataSet {
companies: vec![
"Acme Corporation".to_string(),
"Global Tech Solutions".to_string(),
"Innovation Partners LLC".to_string(),
"Digital Dynamics Inc".to_string(),
"Future Systems Ltd".to_string(),
],
departments: vec![
"Engineering".to_string(),
"Sales & Marketing".to_string(),
"Human Resources".to_string(),
"Operations".to_string(),
"Finance & Accounting".to_string(),
"Research & Development".to_string(),
],
positions: vec![
"Software Engineer".to_string(),
"Product Manager".to_string(),
"Sales Representative".to_string(),
"Data Analyst".to_string(),
"Project Manager".to_string(),
"UX Designer".to_string(),
],
locations: vec![
"San Francisco, CA".to_string(),
"New York, NY".to_string(),
"Austin, TX".to_string(),
"Seattle, WA".to_string(),
"Boston, MA".to_string(),
"Chicago, IL".to_string(),
],
}
}
/// Generate MCP tool call test data
pub fn generate_mcp_test_calls() -> Vec<McpTestCall> {
vec![
McpTestCall {
tool_name: "create_document".to_string(),
args: json!({}),
expected_success: true,
expected_result_keys: vec!["success".to_string(), "document_id".to_string()],
},
McpTestCall {
tool_name: "add_paragraph".to_string(),
args: json!({
"document_id": "test-doc-id",
"text": "Test paragraph content"
}),
expected_success: true,
expected_result_keys: vec!["success".to_string()],
},
McpTestCall {
tool_name: "add_heading".to_string(),
args: json!({
"document_id": "test-doc-id",
"text": "Test Heading",
"level": 1
}),
expected_success: true,
expected_result_keys: vec!["success".to_string()],
},
McpTestCall {
tool_name: "extract_text".to_string(),
args: json!({
"document_id": "test-doc-id"
}),
expected_success: true,
expected_result_keys: vec!["success".to_string(), "text".to_string()],
},
McpTestCall {
tool_name: "get_metadata".to_string(),
args: json!({
"document_id": "test-doc-id"
}),
expected_success: true,
expected_result_keys: vec!["success".to_string(), "metadata".to_string()],
},
]
}
/// Generate performance test scenarios
pub fn generate_performance_scenarios() -> Vec<PerformanceScenario> {
vec![
PerformanceScenario {
name: "Small Document".to_string(),
paragraph_count: 10,
table_count: 1,
list_count: 2,
expected_max_time_ms: 1000,
},
PerformanceScenario {
name: "Medium Document".to_string(),
paragraph_count: 100,
table_count: 5,
list_count: 10,
expected_max_time_ms: 5000,
},
PerformanceScenario {
name: "Large Document".to_string(),
paragraph_count: 500,
table_count: 20,
list_count: 30,
expected_max_time_ms: 15000,
},
PerformanceScenario {
name: "Extra Large Document".to_string(),
paragraph_count: 1000,
table_count: 50,
list_count: 50,
expected_max_time_ms: 30000,
},
]
}
}
/// Complexity levels for generated paragraphs
#[derive(Debug, Clone)]
pub enum ParagraphComplexity {
Simple,
Medium,
Complex,
}
/// Content types for generated tables
#[derive(Debug, Clone)]
pub enum TableContentType {
Generic,
Financial,
Personnel,
Technical,
}
/// Categories for generated lists
#[derive(Debug, Clone)]
pub enum ListCategory {
Tasks,
Features,
Requirements,
Benefits,
}
/// Business data set for realistic testing
#[derive(Debug, Clone)]
pub struct BusinessDataSet {
pub companies: Vec<String>,
pub departments: Vec<String>,
pub positions: Vec<String>,
pub locations: Vec<String>,
}
/// MCP tool call test data
#[derive(Debug, Clone)]
pub struct McpTestCall {
pub tool_name: String,
pub args: Value,
pub expected_success: bool,
pub expected_result_keys: Vec<String>,
}
/// Performance test scenario data
#[derive(Debug, Clone)]
pub struct PerformanceScenario {
pub name: String,
pub paragraph_count: usize,
pub table_count: usize,
pub list_count: usize,
pub expected_max_time_ms: u64,
}
/// Utility functions for test data validation
pub struct TestDataValidator;
impl TestDataValidator {
/// Validate that text contains expected content
pub fn validate_text_content(text: &str, expected_keywords: &[&str]) -> bool {
expected_keywords.iter().all(|keyword| text.contains(keyword))
}
/// Validate table structure
pub fn validate_table_structure(rows: &[Vec<String>], expected_cols: usize) -> bool {
!rows.is_empty() && rows.iter().all(|row| row.len() == expected_cols)
}
/// Validate MCP response structure
pub fn validate_mcp_response(response: &Value, expected_keys: &[String]) -> bool {
expected_keys.iter().all(|key| response.get(key).is_some())
}
/// Generate hash for test data consistency checking
pub fn generate_content_hash(content: &str) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
hasher.finish()
}
}
-237
View File
@@ -1,237 +0,0 @@
use anyhow::Result;
use docx_mcp::docx_handler::{DocxHandler, TableData, TableMerge};
use tempfile::TempDir;
use std::fs;
use zip::ZipArchive;
use docx_mcp::docx_handler::MarginsSpec;
fn open_zip_str(path: &std::path::Path, name: &str) -> Result<String> {
let file = fs::File::open(path)?;
let mut zip = ZipArchive::new(file)?;
let mut f = zip.by_name(name)?;
let mut s = String::new();
use std::io::Read as _;
f.read_to_string(&mut s)?;
Ok(s)
}
#[test]
fn test_embed_page_number_fields_into_header_xml() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?;
let doc_id = handler.create_document()?;
// Add header with placeholder
handler.set_page_numbering(&doc_id, "header", Some("Page {PAGE} of {PAGES}"))?;
// Save once to ensure header part exists
let out_path = temp_dir.path().join("page_fields.docx");
handler.save_document(&doc_id, &out_path)?;
// Embed field codes and resave to propagate to out_path
handler.embed_page_number_fields(&doc_id)?;
handler.save_document(&doc_id, &out_path)?;
// Verify header XML has field runs
let header_xml = open_zip_str(&out_path, "word/header1.xml")?;
assert!(header_xml.contains("w:fldChar") && header_xml.contains("PAGE") && header_xml.contains("NUMPAGES"),
"Expected PAGE/NUMPAGES fields in header1.xml, got: {}", header_xml);
Ok(())
}
#[test]
fn test_section_break_emits_page_break() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?;
let doc_id = handler.create_document()?;
handler.add_paragraph(&doc_id, "Before section", None)?;
handler.add_section_break(&doc_id, Some("A4"), Some("portrait"), None)?;
handler.add_paragraph(&doc_id, "After section", None)?;
let out_path = temp_dir.path().join("section_break.docx");
handler.save_document(&doc_id, &out_path)?;
// Best-effort placeholder: expect a page break in document.xml
let doc_xml = open_zip_str(&out_path, "word/document.xml")?;
assert!(doc_xml.contains("w:br") && doc_xml.contains("w:type=\"page\""),
"Expected a page break to denote section break");
Ok(())
}
#[test]
fn test_table_merge_best_effort_xml() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?;
let doc_id = handler.create_document()?;
// 2x2 table where first row cells are merged (2 columns)
let table = TableData {
rows: vec![
vec!["TopLeft".into(), "RightMergedShouldBeEmpty".into()],
vec!["BottomLeft".into(), "BottomRight".into()],
],
headers: None,
border_style: Some("single".into()),
col_widths: None,
merges: Some(vec![TableMerge { row: 0, col: 0, row_span: 1, col_span: 2 }]),
cell_shading: None,
};
handler.add_table(&doc_id, table)?;
let out_path = temp_dir.path().join("table_merge.docx");
handler.save_document(&doc_id, &out_path)?;
let doc_xml = open_zip_str(&out_path, "word/document.xml")?;
// Expect TopLeft to be present once, and RightMergedShouldBeEmpty to be absent
assert!(doc_xml.contains("TopLeft"));
assert!(!doc_xml.contains("RightMergedShouldBeEmpty"));
// When hi-fidelity-tables is enabled, verify gridSpan
#[cfg(feature = "hi-fidelity-tables")]
{
assert!(doc_xml.contains("w:gridSpan"), "Expected w:gridSpan for horizontal merge");
// For row_span in this test it's 1, so no vMerge expected
assert!(!doc_xml.contains("w:vMerge w:val=\"restart\""));
}
Ok(())
}
#[test]
fn test_table_vmerge_and_col_widths_injection() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?;
let doc_id = handler.create_document()?;
// 3x2 table with a vertical merge on first column (2 rows) and column widths
let table = TableData {
rows: vec![
vec!["A".into(), "B".into()],
vec!["A2-should-be-empty".into(), "C".into()],
vec!["D".into(), "E".into()],
],
headers: None,
border_style: None,
col_widths: Some(vec![2400, 3600]),
merges: Some(vec![TableMerge { row: 0, col: 0, row_span: 2, col_span: 1 }]),
cell_shading: None,
};
handler.add_table(&doc_id, table)?;
let out_path = temp_dir.path().join("table_vmerge.docx");
handler.save_document(&doc_id, &out_path)?;
let doc_xml = open_zip_str(&out_path, "word/document.xml")?;
assert!(!doc_xml.contains("A2-should-be-empty"));
#[cfg(feature = "hi-fidelity-tables")]
{
// Expect vMerge restart and continue
assert!(doc_xml.contains("<w:vMerge w:val=\"restart\"/>"));
assert!(doc_xml.contains("<w:vMerge w:val=\"continue\"/>"));
// Expect tblGrid with specified widths
assert!(doc_xml.contains("<w:tblGrid>"));
assert!(doc_xml.contains("<w:gridCol w:w=\"2400\"/>") && doc_xml.contains("<w:gridCol w:w=\"3600\"/>"));
}
Ok(())
}
#[test]
fn test_footer_field_embedding() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?;
let doc_id = handler.create_document()?;
handler.set_page_numbering(&doc_id, "footer", Some("Page {PAGE} of {PAGES}"))?;
let out_path = temp_dir.path().join("footer_fields.docx");
handler.save_document(&doc_id, &out_path)?;
handler.embed_page_number_fields(&doc_id)?;
handler.save_document(&doc_id, &out_path)?;
let footer_xml = open_zip_str(&out_path, "word/footer1.xml")?;
assert!(footer_xml.contains("w:fldChar") && footer_xml.contains("NUMPAGES"));
Ok(())
}
#[test]
fn test_styles_and_lists_and_sections_hifi_xml() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?;
let doc_id = handler.create_document()?;
// Table with header row to trigger TableHeader style usage
let table = TableData {
rows: vec![
vec!["H1".into(), "H2".into()],
vec!["x".into(), "y".into()],
],
headers: Some(vec!["H1".into(), "H2".into()]),
border_style: None,
col_widths: Some(vec![3000, 3000]),
merges: None,
cell_shading: None,
};
handler.add_table(&doc_id, table)?;
// Ordered and unordered lists
handler.add_list(&doc_id, vec!["one".into(), "two".into()], true)?;
handler.add_list(&doc_id, vec!["dot".into(), "dash".into()], false)?;
// Section setup
handler.add_section_break(&doc_id, Some("Letter"), Some("landscape"), Some(MarginsSpec { top: Some(1.25), bottom: Some(1.25), left: Some(1.0), right: Some(1.0) }))?;
let out_path = temp_dir.path().join("hifi_bundle.docx");
handler.save_document(&doc_id, &out_path)?;
#[cfg(feature = "hi-fidelity-styles")]
{
let styles_xml = open_zip_str(&out_path, "word/styles.xml")?;
assert!(styles_xml.contains("w:styleId=\"TableHeader\""), "Expected TableHeader style defined");
}
#[cfg(feature = "hi-fidelity-lists")]
{
let numbering_xml = open_zip_str(&out_path, "word/numbering.xml")?;
assert!(numbering_xml.contains("w:abstractNumId=\"10\""));
assert!(numbering_xml.contains("w:abstractNumId=\"20\""));
}
#[cfg(feature = "hi-fidelity-sections")]
{
let doc_xml = open_zip_str(&out_path, "word/document.xml")?;
assert!(doc_xml.contains("w:sectPr"));
assert!(doc_xml.contains("w:orient=\"landscape\""));
assert!(doc_xml.contains("w:pgMar"));
}
Ok(())
}
#[test]
fn test_insert_toc_and_bookmark_placeholders() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?;
let doc_id = handler.create_document()?;
handler.add_heading(&doc_id, "Intro", 1)?;
handler.insert_bookmark_after_heading(&doc_id, "Intro", "bm-intro")?;
handler.insert_toc(&doc_id, 1, 3, true)?;
let out_path = temp_dir.path().join("toc_bm.docx");
handler.save_document(&doc_id, &out_path)?;
let doc_xml = open_zip_str(&out_path, "word/document.xml")?;
assert!(doc_xml.contains("__TOC__") || cfg!(feature = "hi-fidelity-toc"), "Expect TOC placeholder or transformed field");
#[cfg(feature = "hi-fidelity-toc")]
{
let doc_xml = open_zip_str(&out_path, "word/document.xml")?;
assert!(doc_xml.contains("w:fldChar") && doc_xml.contains("TOC"));
}
#[cfg(feature = "hi-fidelity-bookmarks")]
{
let doc_xml = open_zip_str(&out_path, "word/document.xml")?;
assert!(!doc_xml.contains("__BOOKMARK__"));
}
Ok(())
}
-72
View File
@@ -1,72 +0,0 @@
use anyhow::Result;
use docx_mcp::docx_handler::{DocxHandler, ImageData};
use tempfile::TempDir;
use std::fs;
use std::path::PathBuf;
use zip::ZipArchive;
#[test]
fn test_golden_xml_links_images_numbering_header() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?;
let doc_id = handler.create_document()?;
// Content: paragraph, hyperlink, image, list with levels, header page numbering
handler.add_paragraph(&doc_id, "Intro paragraph.", None)?;
handler.add_hyperlink(&doc_id, "OpenAI", "https://openai.com")?;
let png_data: Vec<u8> = {
// Small 1x1 PNG
let mut img = ::image::RgbaImage::new(1, 1);
img.put_pixel(0, 0, ::image::Rgba([0, 0, 0, 0]));
let r#dyn = ::image::DynamicImage::ImageRgba8(img);
let mut buf = Vec::new();
r#dyn.write_to(&mut std::io::Cursor::new(&mut buf), ::image::ImageFormat::Png)?;
buf
};
handler.add_image(&doc_id, ImageData { data: png_data, width: Some(10), height: Some(10), alt_text: Some("dot".into()) })?;
handler.add_list(&doc_id, vec!["Item 1".into(), "Item 2".into()], true)?;
handler.add_list_item(&doc_id, "Sub 2.1", 1, true)?;
handler.set_page_numbering(&doc_id, "header", Some("Page {PAGE} of {PAGES}"))?;
// Save DOCX to disk
let out_path = temp_dir.path().join("golden_test.docx");
handler.save_document(&doc_id, &out_path)?;
// Open as zip and inspect XMLs
let file = fs::File::open(&out_path)?;
let mut zip = ZipArchive::new(file)?;
// document.xml should contain hyperlink and drawing (image) and numPr (list numbering)
{
let mut doc_xml = zip.by_name("word/document.xml")?;
let mut s = String::new();
use std::io::Read as _;
doc_xml.read_to_string(&mut s)?;
assert!(s.contains("w:hyperlink") || s.contains(":hyperlink"), "document.xml missing hyperlink element");
assert!(s.contains("w:drawing") || s.contains(":drawing"), "document.xml missing drawing element for image");
assert!(s.contains("w:numPr") || s.contains(":numPr"), "document.xml missing numbering properties for list");
}
// numbering.xml should exist
{
let mut numbering = zip.by_name("word/numbering.xml")?;
let mut s = String::new();
use std::io::Read as _;
numbering.read_to_string(&mut s)?;
assert!(s.contains("w:numbering") || s.contains(":numbering"), "numbering.xml missing numbering root");
}
// header1.xml should contain our page numbering text template
{
let mut header = zip.by_name("word/header1.xml")?;
let mut s = String::new();
use std::io::Read as _;
header.read_to_string(&mut s)?;
assert!(s.contains("Page {PAGE} of {PAGES}"), "header1.xml missing page numbering text");
}
Ok(())
}
-639
View File
@@ -1,639 +0,0 @@
use docx_mcp::docx_tools::DocxToolsProvider;
use docx_mcp::security::SecurityConfig;
use mcp_core::types::ToolResponseContent;
use serde_json::{json, Value};
use tempfile::TempDir;
use pretty_assertions::assert_eq;
use rstest::*;
enum ToolResult {
Success(Value),
Error(String),
}
async fn tool_result(provider: &DocxToolsProvider, name: &str, args: serde_json::Value) -> ToolResult {
let resp = provider.call_tool(name, args).await;
let val = match resp.content.get(0) {
Some(ToolResponseContent::Text(t)) => serde_json::from_str::<Value>(&t.text)
.unwrap_or_else(|_| json!({"success": false, "error": t.text.clone()})),
_ => json!({"success": false, "error": "non-text response"}),
};
if val.get("success").and_then(|v| v.as_bool()).unwrap_or(false) {
ToolResult::Success(val)
} else {
ToolResult::Error(val.get("error").and_then(|v| v.as_str()).unwrap_or("Unknown error").to_string())
}
}
async fn create_test_provider() -> (DocxToolsProvider, TempDir) {
let temp_dir = TempDir::new().unwrap();
let provider = DocxToolsProvider::with_base_dir(temp_dir.path());
(provider, temp_dir)
}
async fn create_test_provider_with_security(config: SecurityConfig) -> (DocxToolsProvider, TempDir) {
let temp_dir = TempDir::new().unwrap();
let provider = DocxToolsProvider::with_base_dir_and_security(temp_dir.path(), config);
(provider, temp_dir)
}
#[tokio::test]
async fn test_list_tools_default_config() {
let (provider, _temp_dir) = create_test_provider().await;
let tools = provider.list_tools().await;
// Should have all tools in default configuration
assert!(tools.len() > 20);
let tool_names: Vec<_> = tools.iter().map(|t| &t.name).collect();
assert!(tool_names.contains(&&"create_document".to_string()));
assert!(tool_names.contains(&&"add_paragraph".to_string()));
assert!(tool_names.contains(&&"convert_to_pdf".to_string()));
assert!(tool_names.contains(&&"extract_text".to_string()));
assert!(tool_names.contains(&&"get_security_info".to_string()));
}
#[tokio::test]
async fn test_list_tools_readonly_config() {
let config = SecurityConfig {
readonly_mode: true,
..Default::default()
};
let (provider, _temp_dir) = create_test_provider_with_security(config).await;
let tools = provider.list_tools().await;
let tool_names: Vec<_> = tools.iter().map(|t| &t.name).collect();
// Should include readonly tools
assert!(tool_names.contains(&&"extract_text".to_string()));
assert!(tool_names.contains(&&"get_metadata".to_string()));
assert!(tool_names.contains(&&"search_text".to_string()));
// Should not include write tools
assert!(!tool_names.contains(&&"create_document".to_string()));
assert!(!tool_names.contains(&&"add_paragraph".to_string()));
assert!(!tool_names.contains(&&"save_document".to_string()));
}
#[tokio::test]
async fn test_create_document_tool() {
let (provider, _temp_dir) = create_test_provider().await;
let result = tool_result(&provider, "create_document", json!({})).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
assert!(value["document_id"].is_string());
let doc_id = value["document_id"].as_str().unwrap();
assert!(!doc_id.is_empty());
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
}
#[tokio::test]
async fn test_add_paragraph_tool() {
let (provider, _temp_dir) = create_test_provider().await;
// First create a document
let create_result = tool_result(&provider, "create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Add paragraph
let args = json!({
"document_id": doc_id,
"text": "Test paragraph content"
});
let result = tool_result(&provider, "add_paragraph", args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
// Verify content was added
let extract_args = json!({"document_id": doc_id});
let extract_result = tool_result(&provider, "extract_text", extract_args).await;
match extract_result {
ToolResult::Success(value) => {
let text = value["text"].as_str().unwrap();
assert!(text.contains("Test paragraph content"));
}
ToolResult::Error(e) => panic!("Failed to extract text: {}", e),
}
}
#[tokio::test]
async fn test_add_paragraph_with_style() {
let (provider, _temp_dir) = create_test_provider().await;
let create_result = tool_result(&provider, "create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
let args = json!({
"document_id": doc_id,
"text": "Styled paragraph",
"style": {
"font_size": 16,
"bold": true,
"color": "#FF0000",
"alignment": "center"
}
});
let result = tool_result(&provider, "add_paragraph", args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
}
#[tokio::test]
async fn test_add_table_tool() {
let (provider, _temp_dir) = create_test_provider().await;
let create_result = tool_result(&provider, "create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
let args = json!({
"document_id": doc_id,
"rows": [
["Name", "Age", "City"],
["Alice", "30", "New York"],
["Bob", "25", "Los Angeles"]
]
});
let result = tool_result(&provider, "add_table", args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
// Verify table content
let extract_args = json!({"document_id": doc_id});
let extract_result = tool_result(&provider, "extract_text", extract_args).await;
match extract_result {
ToolResult::Success(value) => {
let text = value["text"].as_str().unwrap();
assert!(text.contains("Alice"));
assert!(text.contains("New York"));
}
ToolResult::Error(e) => panic!("Failed to extract text: {}", e),
}
}
#[tokio::test]
async fn test_add_heading_tool() {
let (provider, _temp_dir) = create_test_provider().await;
let create_result = tool_result(&provider, "create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Test different heading levels
for level in 1..=6 {
let args = json!({
"document_id": doc_id,
"text": format!("Heading Level {}", level),
"level": level
});
let result = tool_result(&provider, "add_heading", args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
}
ToolResult::Error(e) => panic!("Expected success for level {}, got error: {}", level, e),
}
}
}
#[tokio::test]
async fn test_add_list_tool() {
let (provider, _temp_dir) = create_test_provider().await;
let create_result = tool_result(&provider, "create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Test ordered list
let ordered_args = json!({
"document_id": doc_id,
"items": ["First item", "Second item", "Third item"],
"ordered": true
});
let result = tool_result(&provider, "add_list", ordered_args).await;
assert!(matches!(result, ToolResult::Success(_)));
// Test unordered list
let unordered_args = json!({
"document_id": doc_id,
"items": ["Bullet one", "Bullet two", "Bullet three"],
"ordered": false
});
let result = tool_result(&provider, "add_list", unordered_args).await;
assert!(matches!(result, ToolResult::Success(_)));
}
#[tokio::test]
async fn test_get_metadata_tool() {
let (provider, _temp_dir) = create_test_provider().await;
let create_result = tool_result(&provider, "create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
let args = json!({"document_id": doc_id});
let result = tool_result(&provider, "get_metadata", args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let metadata = &value["metadata"];
assert_eq!(metadata["id"], doc_id);
assert!(metadata["path"].is_string());
assert!(metadata["created_at"].is_string());
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
}
#[tokio::test]
async fn test_search_text_tool() {
let (provider, _temp_dir) = create_test_provider().await;
let create_result = tool_result(&provider, "create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Add some content to search
let add_args = json!({
"document_id": doc_id,
"text": "This is a test document with searchable content. The word test appears multiple times."
});
tool_result(&provider, "add_paragraph", add_args).await;
// Search for text
let search_args = json!({
"document_id": doc_id,
"search_term": "test",
"case_sensitive": false
});
let result = tool_result(&provider, "search_text", search_args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let matches = value["matches"].as_array().unwrap();
assert!(matches.len() > 0);
assert!(value["total_matches"].as_u64().unwrap() > 0);
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
}
#[tokio::test]
async fn test_get_word_count_tool() {
let (provider, _temp_dir) = create_test_provider().await;
let create_result = tool_result(&provider, "create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Add content with known word count
let content = "This sentence has exactly five words. This is another sentence with seven words total.";
let add_args = json!({
"document_id": doc_id,
"text": content
});
tool_result(&provider, "add_paragraph", add_args).await;
let args = json!({"document_id": doc_id});
let result = tool_result(&provider, "get_word_count", args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let stats = &value["statistics"];
assert!(stats["words"].as_u64().unwrap() > 10);
assert!(stats["characters"].as_u64().unwrap() > 0);
assert!(stats["sentences"].as_u64().unwrap() >= 2);
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
}
#[tokio::test]
async fn test_get_security_info_tool() {
let config = SecurityConfig {
readonly_mode: true,
sandbox_mode: true,
..Default::default()
};
let (provider, _temp_dir) = create_test_provider_with_security(config).await;
let result = tool_result(&provider, "get_security_info", json!({})).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let security = &value["security"];
assert_eq!(security["readonly_mode"], true);
assert_eq!(security["sandbox_mode"], true);
assert!(security["summary"].is_string());
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
}
#[tokio::test]
async fn test_readonly_mode_blocks_write_operations() {
let config = SecurityConfig {
readonly_mode: true,
..Default::default()
};
let (provider, _temp_dir) = create_test_provider_with_security(config).await;
// Should fail to create document in readonly mode
let result = tool_result(&provider, "create_document", json!({})).await;
match result {
ToolResult::Error(e) => {
assert!(e.contains("Security check failed"));
assert!(e.contains("Command not allowed"));
}
ToolResult::Success(_) => panic!("Expected security error, got success"),
}
}
#[tokio::test]
async fn test_document_not_found_error() {
let (provider, _temp_dir) = create_test_provider().await;
let args = json!({"document_id": "nonexistent-doc-id"});
let result = tool_result(&provider, "extract_text", args).await;
match result {
ToolResult::Success(value) => {
assert!(!value["success"].as_bool().unwrap());
assert!(value["error"].as_str().unwrap().contains("Document not found"));
}
ToolResult::Error(_) => {
// This is also acceptable - depends on implementation
}
}
}
#[tokio::test]
async fn test_invalid_tool_name() {
let (provider, _temp_dir) = create_test_provider().await;
let result = tool_result(&provider, "nonexistent_tool", json!({})).await;
match result {
ToolResult::Success(value) => {
assert!(!value["success"].as_bool().unwrap());
let err = value["error"].as_str().unwrap();
assert!(err.contains("Unknown or unsupported tool") || err.contains("Unknown tool"));
}
ToolResult::Error(e) => {
assert!(e.contains("Unknown or unsupported tool") || e.contains("Unknown tool"));
}
}
}
#[tokio::test]
async fn test_multiple_documents() {
let (provider, _temp_dir) = create_test_provider().await;
let mut doc_ids = Vec::new();
// Create multiple documents
for i in 0..3 {
let result = tool_result(&provider, "create_document", json!({})).await;
let doc_id = match result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document {}", i),
};
// Add unique content to each
let args = json!({
"document_id": doc_id,
"text": format!("Document {} content", i)
});
tool_result(&provider, "add_paragraph", args).await;
doc_ids.push(doc_id);
}
// List documents
let list_result = tool_result(&provider, "list_documents", json!({})).await;
match list_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let documents = value["documents"].as_array().unwrap();
assert!(documents.len() >= 3);
}
ToolResult::Error(e) => panic!("Failed to list documents: {}", e),
}
// Verify each document has its unique content
for (i, doc_id) in doc_ids.iter().enumerate() {
let args = json!({"document_id": doc_id});
let result = tool_result(&provider, "extract_text", args).await;
match result {
ToolResult::Success(value) => {
let text = value["text"].as_str().unwrap();
assert!(text.contains(&format!("Document {} content", i)));
}
ToolResult::Error(e) => panic!("Failed to extract text from document {}: {}", i, e),
}
}
}
#[tokio::test]
async fn test_export_to_markdown() {
let (provider, temp_dir) = create_test_provider().await;
let create_result = tool_result(&provider, "create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Add content
tool_result(&provider, "add_heading", json!({
"document_id": doc_id,
"text": "Test Document",
"level": 1
})).await;
tool_result(&provider, "add_paragraph", json!({
"document_id": doc_id,
"text": "This is a test paragraph."
})).await;
// Export to markdown
let output_path = temp_dir.path().join("test_export.md");
let args = json!({
"document_id": doc_id,
"output_path": output_path.to_str().unwrap()
});
let result = tool_result(&provider, "export_to_markdown", args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
assert!(output_path.exists());
let content = std::fs::read_to_string(&output_path).unwrap();
assert!(content.contains("# Test Document"));
assert!(content.contains("test paragraph"));
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
}
#[tokio::test]
async fn test_export_to_html() {
let (provider, temp_dir) = create_test_provider().await;
let create_result = tool_result(&provider, "create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Add content
tool_result(&provider, "add_heading", json!({
"document_id": doc_id,
"text": "Test Document",
"level": 1
})).await;
tool_result(&provider, "add_paragraph", json!({
"document_id": doc_id,
"text": "This is a test paragraph."
})).await;
// Export to HTML
let output_path = temp_dir.path().join("test_export.html");
let args = json!({
"document_id": doc_id,
"output_path": output_path.to_str().unwrap()
});
let result = tool_result(&provider, "export_to_html", args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
assert!(output_path.exists());
let html = std::fs::read_to_string(&output_path).unwrap();
assert!(html.contains("<h1>") || html.contains("<h2>") || html.contains("<p>"));
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
}
#[tokio::test]
async fn test_get_storage_info_tool() {
let (provider, _temp_dir) = create_test_provider().await;
// Create a couple of docs to ensure some files exist
for _ in 0..2 {
let _ = tool_result(&provider, "create_document", json!({})).await;
}
let result = tool_result(&provider, "get_storage_info", json!({})).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let storage = &value["storage"];
assert!(storage["file_count"].is_number());
assert!(storage["total_bytes"].is_number());
}
ToolResult::Error(e) => panic!("get_storage_info failed: {}", e),
}
}
#[tokio::test]
async fn test_list_tools_includes_new_exports() {
let (provider, _temp_dir) = create_test_provider().await;
let tools = provider.list_tools().await;
let names: Vec<_> = tools.iter().map(|t| t.name.clone()).collect();
assert!(names.contains(&"export_to_markdown".to_string()));
assert!(names.contains(&"export_to_html".to_string()));
}
// Parametrized test using rstest
#[rstest]
#[case("create_document", json!({}))]
#[case("list_documents", json!({}))]
#[case("get_security_info", json!({}))]
#[tokio::test]
async fn test_tools_without_document_id(#[case] tool_name: &str, #[case] args: serde_json::Value) {
let (provider, _temp_dir) = create_test_provider().await;
let result = tool_result(&provider, tool_name, args).await;
// These tools should work without requiring a document_id
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap_or(false));
}
ToolResult::Error(e) => panic!("Tool {} failed: {}", tool_name, e),
}
}
#[tokio::test]
async fn test_tool_input_validation() {
let (provider, _temp_dir) = create_test_provider().await;
// Missing required arguments should fail gracefully
let result = tool_result(&provider, "add_paragraph", json!({})).await;
match result {
ToolResult::Success(value) => {
// Should fail due to missing document_id
assert!(!value["success"].as_bool().unwrap_or(true));
}
ToolResult::Error(_) => {
// This is also acceptable
}
}
}
-582
View File
@@ -1,582 +0,0 @@
use anyhow::Result;
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
use docx_mcp::pure_converter::PureRustConverter;
use docx_mcp::docx_tools::DocxToolsProvider;
use docx_mcp::security::SecurityConfig;
use mcp_core::types::{CallToolResponse, ToolResponseContent};
use serde_json::{json, Value};
use tempfile::TempDir;
use std::time::{Duration, Instant};
use std::sync::{Arc, Mutex};
use std::thread;
use pretty_assertions::assert_eq;
const PERFORMANCE_TIMEOUT: Duration = Duration::from_secs(30);
const STRESS_TEST_ITERATIONS: usize = 100;
#[test]
fn test_large_document_performance() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new_with_base_dir(temp_dir.path()).unwrap();
let start = Instant::now();
let doc_id = handler.create_document().unwrap();
let creation_time = start.elapsed();
println!("Document creation took: {:?}", creation_time);
assert!(creation_time < Duration::from_millis(500), "Document creation should be fast");
let start = Instant::now();
// Add substantial content
for i in 0..1000 {
if i % 50 == 0 {
handler.add_heading(&doc_id, &format!("Section {}", i / 50 + 1), 2)?;
}
let content = format!(
"This is paragraph number {} in our performance test. It contains enough text to make the test meaningful and simulate real-world usage patterns. The paragraph includes various punctuation marks, numbers like {}, and other elements that might affect processing performance.",
i, i * 7
);
handler.add_paragraph(&doc_id, &content, None)?;
// Add a table every 100 paragraphs
if i % 100 == 99 {
let table_data = TableData {
rows: vec![
vec!["Item".to_string(), "Value".to_string(), "Status".to_string()],
vec![format!("Item {}", i), format!("${}.00", i * 10), "Active".to_string()],
],
headers: Some(vec!["Item".to_string(), "Value".to_string(), "Status".to_string()]),
border_style: Some("single".to_string()),
col_widths: None,
merges: None,
cell_shading: None,
};
handler.add_table(&doc_id, table_data)?;
}
}
let content_addition_time = start.elapsed();
println!("Adding 1000 paragraphs took: {:?}", content_addition_time);
assert!(content_addition_time < PERFORMANCE_TIMEOUT, "Content addition took too long");
// Test text extraction performance
let start = Instant::now();
let text = handler.extract_text(&doc_id)?;
let extraction_time = start.elapsed();
println!("Text extraction took: {:?}", extraction_time);
println!("Extracted text length: {} characters", text.len());
assert!(extraction_time < Duration::from_secs(10), "Text extraction should be reasonably fast");
assert!(text.len() > 100000, "Should extract substantial amount of text");
// Test PDF conversion performance
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let pdf_path = temp_dir.path().join("large_performance_test.pdf");
let start = Instant::now();
converter.convert_docx_to_pdf(&metadata.path, &pdf_path)?;
let conversion_time = start.elapsed();
println!("PDF conversion took: {:?}", conversion_time);
assert!(conversion_time < PERFORMANCE_TIMEOUT, "PDF conversion took too long");
assert!(pdf_path.exists(), "PDF should be created");
let pdf_size = std::fs::metadata(&pdf_path)?.len();
println!("Generated PDF size: {} bytes", pdf_size);
assert!(pdf_size > 50000, "PDF should have substantial size");
Ok(())
}
#[test]
fn test_concurrent_document_stress() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let temp_path = Arc::new(temp_dir.path().to_path_buf());
let results = Arc::new(Mutex::new(Vec::new()));
let thread_count = 8;
let operations_per_thread = 20;
let start = Instant::now();
let handles: Vec<_> = (0..thread_count).map(|thread_id| {
let temp_path = Arc::clone(&temp_path);
let results = Arc::clone(&results);
thread::spawn(move || -> Result<()> {
let mut handler = DocxHandler::new_with_base_dir(&*temp_path)?;
let mut local_results = Vec::new();
for op_id in 0..operations_per_thread {
let doc_start = Instant::now();
// Create document
let doc_id = handler.create_document()?;
// Add varied content
handler.add_heading(&doc_id, &format!("Thread {} Document {}", thread_id, op_id), 1)?;
for i in 0..10 {
let content = format!("Thread {} operation {} paragraph {}", thread_id, op_id, i);
handler.add_paragraph(&doc_id, &content, None)?;
}
// Add a small table
let table_data = TableData {
rows: vec![
vec!["Col1".to_string(), "Col2".to_string()],
vec![format!("T{}", thread_id), format!("O{}", op_id)],
],
headers: None,
border_style: Some("single".to_string()),
col_widths: None,
merges: None,
cell_shading: None,
};
handler.add_table(&doc_id, table_data)?;
// Extract text
let text = handler.extract_text(&doc_id)?;
assert!(text.contains(&format!("Thread {} Document {}", thread_id, op_id)));
let doc_duration = doc_start.elapsed();
local_results.push((thread_id, op_id, doc_duration));
// Cleanup
handler.close_document(&doc_id)?;
}
// Store results
{
let mut results_guard = results.lock().unwrap();
results_guard.extend(local_results);
}
Ok(())
})
}).collect();
// Wait for all threads
for handle in handles {
handle.join().unwrap()?;
}
let total_duration = start.elapsed();
let results_guard = results.lock().unwrap();
println!("Concurrent stress test completed in: {:?}", total_duration);
println!("Total operations: {}", results_guard.len());
let avg_duration = results_guard.iter()
.map(|(_, _, duration)| duration.as_millis())
.sum::<u128>() as f64 / results_guard.len() as f64;
println!("Average operation duration: {:.2}ms", avg_duration);
// Verify all operations completed
assert_eq!(results_guard.len(), thread_count * operations_per_thread);
assert!(total_duration < Duration::from_secs(60), "Stress test took too long");
assert!(avg_duration < 1000.0, "Average operation should be under 1 second");
Ok(())
}
#[test]
fn test_memory_intensive_operations() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new_with_base_dir(temp_dir.path()).unwrap();
let mut doc_ids = Vec::new();
// Create many documents simultaneously
for i in 0..50 {
let doc_id = handler.create_document()?;
// Add substantial content to each
handler.add_heading(&doc_id, &format!("Memory Test Document {}", i), 1)?;
for j in 0..100 {
let content = format!(
"Document {} paragraph {}. This paragraph contains substantial text content to test memory usage patterns. It includes various data that might accumulate in memory during processing and needs to be handled efficiently by the system.",
i, j
);
handler.add_paragraph(&doc_id, &content, None)?;
}
// Add a large table
let mut table_rows = vec![vec!["ID".to_string(), "Name".to_string(), "Description".to_string()]];
for k in 0..20 {
table_rows.push(vec![
format!("ID-{}", k),
format!("Item-{}", k),
format!("Description for item {} in document {}", k, i),
]);
}
let table_data = TableData {
rows: table_rows,
headers: Some(vec!["ID".to_string(), "Name".to_string(), "Description".to_string()]),
border_style: Some("single".to_string()),
col_widths: None,
merges: None,
cell_shading: None,
};
handler.add_table(&doc_id, table_data)?;
doc_ids.push(doc_id);
}
println!("Created {} documents with substantial content", doc_ids.len());
// Test that all documents are accessible
for (i, doc_id) in doc_ids.iter().enumerate() {
let text = handler.extract_text(doc_id)?;
assert!(text.contains(&format!("Memory Test Document {}", i)));
assert!(text.len() > 10000, "Document should have substantial text");
}
// Test batch operations
let start = Instant::now();
let mut total_text_length = 0;
for doc_id in &doc_ids {
let text = handler.extract_text(doc_id)?;
total_text_length += text.len();
}
let batch_extraction_time = start.elapsed();
println!("Batch text extraction took: {:?}", batch_extraction_time);
println!("Total extracted text: {} characters", total_text_length);
assert!(batch_extraction_time < Duration::from_secs(30), "Batch extraction should be reasonable");
assert!(total_text_length > 500000, "Should extract substantial total text");
// Cleanup all documents
for doc_id in doc_ids {
handler.close_document(&doc_id)?;
}
Ok(())
}
#[test]
fn test_mcp_tool_performance() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let provider = DocxToolsProvider::with_base_dir(temp_dir.path());
let mut operation_times = Vec::new();
// Test document creation performance
let start = Instant::now();
let create_resp: CallToolResponse = tokio_test::block_on(async {
provider.call_tool("create_document", json!({})).await
});
let create_result = match create_resp.content.get(0) {
Some(ToolResponseContent::Text(t)) => serde_json::from_str::<Value>(&t.text)
.map_err(|e| e.to_string()),
_ => Err("non-text response".to_string())
};
let creation_time = start.elapsed();
operation_times.push(("create_document", creation_time));
let doc_id = match create_result {
Ok(value) if value.get("success").and_then(|v| v.as_bool()).unwrap_or(false) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Test paragraph addition performance
let start = Instant::now();
for i in 0..100 {
let args = json!({
"document_id": doc_id,
"text": format!("Performance test paragraph {} with substantial content for timing measurements", i)
});
let result: CallToolResponse = tokio_test::block_on(async {
provider.call_tool("add_paragraph", args).await
});
if let Some(ToolResponseContent::Text(t)) = result.content.get(0) {
let v: Value = serde_json::from_str(&t.text).unwrap_or(json!({"success": false}));
assert!(v.get("success").and_then(|b| b.as_bool()).unwrap_or(false), "Failed to add paragraph {}: {}", i, t.text);
} else {
panic!("Non-text response for add_paragraph");
}
}
let paragraph_addition_time = start.elapsed();
operation_times.push(("add_100_paragraphs", paragraph_addition_time));
// Test heading performance
let start = Instant::now();
for level in 1..=6 {
let args = json!({
"document_id": doc_id,
"text": format!("Heading Level {}", level),
"level": level
});
tokio_test::block_on(async {
provider.call_tool("add_heading", args).await
});
}
let heading_time = start.elapsed();
operation_times.push(("add_headings", heading_time));
// Test table performance
let start = Instant::now();
let table_args = json!({
"document_id": doc_id,
"rows": [
["Product", "Price", "Quantity", "Total"],
["Item 1", "$10.00", "5", "$50.00"],
["Item 2", "$15.00", "3", "$45.00"],
["Item 3", "$12.00", "7", "$84.00"],
["Item 4", "$8.00", "10", "$80.00"]
]
});
tokio_test::block_on(async {
provider.call_tool("add_table", table_args).await
});
let table_time = start.elapsed();
operation_times.push(("add_table", table_time));
// Test text extraction performance
let start = Instant::now();
let extract_args = json!({"document_id": doc_id});
let extract_resp: CallToolResponse = tokio_test::block_on(async {
provider.call_tool("extract_text", extract_args).await
});
let extraction_time = start.elapsed();
operation_times.push(("extract_text", extraction_time));
match extract_resp.content.get(0) {
Some(ToolResponseContent::Text(t)) => {
let value: Value = serde_json::from_str(&t.text).unwrap();
let text = value["text"].as_str().unwrap();
println!("Extracted text length: {} characters", text.len());
assert!(text.len() > 5000, "Should extract substantial text");
},
_ => panic!("Text extraction failed"),
}
// Test metadata retrieval performance
let start = Instant::now();
let metadata_args = json!({"document_id": doc_id});
tokio_test::block_on(async {
provider.call_tool("get_metadata", metadata_args).await
});
let metadata_time = start.elapsed();
operation_times.push(("get_metadata", metadata_time));
// Print performance results
println!("\nMCP Tool Performance Results:");
for (operation, duration) in &operation_times {
println!("{}: {:?}", operation, duration);
}
// Verify reasonable performance
for (operation, duration) in &operation_times {
match operation.as_ref() {
"create_document" => assert!(duration < &Duration::from_millis(500), "Document creation too slow"),
"add_100_paragraphs" => assert!(duration < &Duration::from_secs(10), "Paragraph addition too slow"),
"extract_text" => assert!(duration < &Duration::from_secs(5), "Text extraction too slow"),
_ => assert!(duration < &Duration::from_secs(2), "Operation {} too slow", operation),
}
}
Ok(())
}
#[test]
fn test_security_overhead_performance() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
// Test with default (permissive) security
let default_provider = DocxToolsProvider::with_base_dir(temp_dir.path());
// Test with restrictive security
let restrictive_config = SecurityConfig {
readonly_mode: true,
sandbox_mode: true,
max_document_size: 1024 * 1024, // 1MB
max_open_documents: 10,
allow_external_tools: false,
allow_network: false,
..Default::default()
};
let restrictive_provider = DocxToolsProvider::with_base_dir_and_security(temp_dir.path(), restrictive_config);
let operations = vec![
("list_documents", json!({})),
("get_security_info", json!({})),
];
for (operation, args) in operations {
// Test default provider
let start = Instant::now();
let _result = tokio_test::block_on(async {
default_provider.call_tool(operation, args.clone()).await
});
let default_time = start.elapsed();
// Test restrictive provider
let start = Instant::now();
let _result = tokio_test::block_on(async {
restrictive_provider.call_tool(operation, args.clone()).await
});
let restrictive_time = start.elapsed();
println!("Operation {}: Default={:?}, Restrictive={:?}",
operation, default_time, restrictive_time);
// Security overhead should be reasonable but may vary on CI; allow up to 15x for very fast baselines
let overhead_ratio = restrictive_time.as_nanos() as f64 / default_time.as_nanos() as f64;
assert!(overhead_ratio < 15.0, "Security overhead too high for {}: {}x", operation, overhead_ratio);
}
Ok(())
}
#[test]
fn test_conversion_performance_scaling() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let converter = PureRustConverter::new();
let document_sizes = vec![10, 50, 100, 250];
let mut performance_data = Vec::new();
for &size in &document_sizes {
let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?;
let doc_id = handler.create_document()?;
// Create document with specified number of paragraphs
handler.add_heading(&doc_id, &format!("Test Document - {} paragraphs", size), 1)?;
for i in 0..size {
let content = format!("Paragraph {} content for performance scaling test. This paragraph contains enough text to make the performance test meaningful and realistic.", i);
handler.add_paragraph(&doc_id, &content, None)?;
if i % 20 == 19 {
handler.add_heading(&doc_id, &format!("Section {}", i / 20 + 1), 2)?;
}
}
let metadata = handler.get_metadata(&doc_id)?;
// Test text extraction scaling
let start = Instant::now();
let text = handler.extract_text(&doc_id)?;
let extraction_time = start.elapsed();
// Test PDF conversion scaling
let pdf_path = temp_dir.path().join(format!("scale_test_{}.pdf", size));
let start = Instant::now();
converter.convert_docx_to_pdf(&metadata.path, &pdf_path)?;
let conversion_time = start.elapsed();
performance_data.push((size, text.len(), extraction_time, conversion_time));
println!("Size: {} paragraphs, Text: {} chars, Extract: {:?}, Convert: {:?}",
size, text.len(), extraction_time, conversion_time);
handler.close_document(&doc_id)?;
}
// Analyze scaling behavior
for i in 1..performance_data.len() {
let (prev_size, _, prev_extract, prev_convert) = performance_data[i-1];
let (curr_size, _, curr_extract, curr_convert) = performance_data[i];
let size_ratio = curr_size as f64 / prev_size as f64;
let extract_ratio = curr_extract.as_nanos() as f64 / prev_extract.as_nanos() as f64;
let convert_ratio = curr_convert.as_nanos() as f64 / prev_convert.as_nanos() as f64;
println!("Size {}{}: Extract scaling {:.2}, Convert scaling {:.2}",
prev_size, curr_size, extract_ratio / size_ratio, convert_ratio / size_ratio);
// Performance should scale reasonably (not exponentially)
assert!(extract_ratio / size_ratio < 3.0, "Text extraction scaling too poor");
assert!(convert_ratio / size_ratio < 5.0, "PDF conversion scaling too poor");
}
Ok(())
}
#[test]
fn test_error_handling_performance() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let provider = DocxToolsProvider::with_base_dir(temp_dir.path());
let error_operations = vec![
("extract_text", json!({"document_id": "nonexistent"})),
("add_paragraph", json!({"document_id": "fake", "text": "test"})),
("get_metadata", json!({"document_id": "invalid"})),
("unknown_tool", json!({})),
];
for (operation, args) in error_operations {
let start = Instant::now();
let result = tokio_test::block_on(async {
provider.call_tool(operation, args).await
});
let error_time = start.elapsed();
println!("Error handling for {}: {:?}", operation, error_time);
// Error handling should be fast
assert!(error_time < Duration::from_millis(100),
"Error handling for {} too slow: {:?}", operation, error_time);
// Should return appropriate error
// Ensure we got a response shape; don't match legacy types here
}
Ok(())
}
#[test]
fn test_resource_cleanup_performance() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?;
let document_count = 50;
let mut doc_ids = Vec::new();
// Create many documents
let creation_start = Instant::now();
for i in 0..document_count {
let doc_id = handler.create_document()?;
handler.add_paragraph(&doc_id, &format!("Document {} content", i), None)?;
doc_ids.push(doc_id);
}
let creation_time = creation_start.elapsed();
println!("Created {} documents in {:?}", document_count, creation_time);
// Verify all documents exist
let initial_count = handler.list_documents().len();
assert_eq!(initial_count, document_count);
// Test cleanup performance
let cleanup_start = Instant::now();
for doc_id in doc_ids {
handler.close_document(&doc_id)?;
}
let cleanup_time = cleanup_start.elapsed();
println!("Cleaned up {} documents in {:?}", document_count, cleanup_time);
// Verify cleanup worked
let final_count = handler.list_documents().len();
assert_eq!(final_count, 0);
// Cleanup should be reasonably fast
assert!(cleanup_time < Duration::from_secs(5), "Cleanup took too long");
let avg_cleanup_time = cleanup_time.as_nanos() / document_count as u128;
println!("Average cleanup time per document: {}ns", avg_cleanup_time);
Ok(())
}
-353
View File
@@ -1,353 +0,0 @@
use docx_mcp::security::{SecurityConfig, SecurityMiddleware, SecurityError};
use serde_json::json;
use std::collections::HashSet;
use pretty_assertions::assert_eq;
use rstest::*;
#[test]
fn test_default_security_config() {
let config = SecurityConfig::default();
assert!(!config.readonly_mode);
assert!(config.command_whitelist.is_none());
assert!(config.command_blacklist.is_none());
assert_eq!(config.max_document_size, 100 * 1024 * 1024);
assert_eq!(config.max_open_documents, 50);
assert!(config.allow_external_tools);
assert!(config.allow_network);
assert!(!config.sandbox_mode);
}
#[test]
fn test_readonly_mode_allows_only_safe_commands() {
let config = SecurityConfig {
readonly_mode: true,
..Default::default()
};
// Should allow readonly commands
assert!(config.is_command_allowed("open_document"));
assert!(config.is_command_allowed("extract_text"));
assert!(config.is_command_allowed("get_metadata"));
assert!(config.is_command_allowed("search_text"));
assert!(config.is_command_allowed("export_to_markdown"));
// Should block write commands
assert!(!config.is_command_allowed("create_document"));
assert!(!config.is_command_allowed("add_paragraph"));
assert!(!config.is_command_allowed("save_document"));
assert!(!config.is_command_allowed("find_and_replace"));
assert!(!config.is_command_allowed("convert_to_pdf"));
}
#[test]
fn test_command_whitelist() {
let mut whitelist = HashSet::new();
whitelist.insert("open_document".to_string());
whitelist.insert("extract_text".to_string());
let config = SecurityConfig {
command_whitelist: Some(whitelist),
command_blacklist: None,
..Default::default()
};
// Should allow whitelisted commands
assert!(config.is_command_allowed("open_document"));
assert!(config.is_command_allowed("extract_text"));
// Should block non-whitelisted commands
assert!(!config.is_command_allowed("create_document"));
assert!(!config.is_command_allowed("add_paragraph"));
assert!(!config.is_command_allowed("get_metadata"));
}
#[test]
fn test_command_blacklist() {
let mut blacklist = HashSet::new();
blacklist.insert("save_document".to_string());
blacklist.insert("convert_to_pdf".to_string());
let config = SecurityConfig {
command_whitelist: None,
command_blacklist: Some(blacklist),
..Default::default()
};
// Should allow non-blacklisted commands
assert!(config.is_command_allowed("open_document"));
assert!(config.is_command_allowed("extract_text"));
assert!(config.is_command_allowed("add_paragraph"));
// Should block blacklisted commands
assert!(!config.is_command_allowed("save_document"));
assert!(!config.is_command_allowed("convert_to_pdf"));
}
#[test]
fn test_whitelist_overrides_blacklist() {
let mut whitelist = HashSet::new();
whitelist.insert("save_document".to_string());
let mut blacklist = HashSet::new();
blacklist.insert("save_document".to_string());
let config = SecurityConfig {
command_whitelist: Some(whitelist),
command_blacklist: Some(blacklist),
..Default::default()
};
// Whitelist should take precedence
assert!(config.is_command_allowed("save_document"));
}
#[test]
fn test_external_tools_restriction() {
let config = SecurityConfig {
allow_external_tools: false,
..Default::default()
};
// Should block conversion commands that might use external tools
assert!(!config.is_command_allowed("convert_to_pdf"));
assert!(!config.is_command_allowed("convert_to_images"));
// Should allow other commands
assert!(config.is_command_allowed("open_document"));
assert!(config.is_command_allowed("add_paragraph"));
}
#[test]
fn test_security_middleware_command_check() {
let config = SecurityConfig {
readonly_mode: true,
..Default::default()
};
let middleware = SecurityMiddleware::new(config);
let safe_args = json!({"document_id": "test"});
// Should pass readonly commands
let result = middleware.check_command("extract_text", &safe_args);
assert!(result.is_ok());
// Should fail write commands
let result = middleware.check_command("add_paragraph", &safe_args);
assert!(matches!(result, Err(SecurityError::CommandNotAllowed(_))));
}
#[test]
fn test_sandbox_mode_path_restrictions() {
let config = SecurityConfig {
sandbox_mode: true,
..Default::default()
};
let middleware = SecurityMiddleware::new(config);
// Should allow temp directory paths
let temp_args = json!({"path": "/tmp/docx-mcp/test.docx"});
let result = middleware.check_command("open_document", &temp_args);
assert!(result.is_ok());
// Should block paths outside temp directory
let home_args = json!({"path": "/home/user/documents/test.docx"});
let result = middleware.check_command("open_document", &home_args);
assert!(matches!(result, Err(SecurityError::PathNotAllowed(_))));
}
#[test]
fn test_file_size_limits() {
use tempfile::NamedTempFile;
use std::io::Write;
let config = SecurityConfig {
max_document_size: 100, // 100 bytes limit
..Default::default()
};
let middleware = SecurityMiddleware::new(config);
// Create a test file larger than limit
let mut temp_file = NamedTempFile::new().unwrap();
let large_content = vec![0u8; 200]; // 200 bytes
temp_file.write_all(&large_content).unwrap();
temp_file.flush().unwrap();
let args = json!({"path": temp_file.path().to_str().unwrap()});
let result = middleware.check_command("open_document", &args);
assert!(matches!(result, Err(SecurityError::FileTooLarge { .. })));
}
#[test]
fn test_readonly_commands_list() {
let readonly_commands = SecurityConfig::get_readonly_commands();
// Should include expected readonly commands
assert!(readonly_commands.contains("open_document"));
assert!(readonly_commands.contains("extract_text"));
assert!(readonly_commands.contains("get_metadata"));
assert!(readonly_commands.contains("search_text"));
assert!(readonly_commands.contains("analyze_formatting"));
// Should not include write commands
assert!(!readonly_commands.contains("create_document"));
assert!(!readonly_commands.contains("add_paragraph"));
assert!(!readonly_commands.contains("save_document"));
}
#[test]
fn test_write_commands_list() {
let write_commands = SecurityConfig::get_write_commands();
// Should include expected write commands
assert!(write_commands.contains("create_document"));
assert!(write_commands.contains("add_paragraph"));
assert!(write_commands.contains("save_document"));
assert!(write_commands.contains("find_and_replace"));
// Should not include readonly commands
assert!(!write_commands.contains("open_document"));
assert!(!write_commands.contains("extract_text"));
assert!(!write_commands.contains("get_metadata"));
}
#[test]
fn test_security_summary() {
let config = SecurityConfig {
readonly_mode: true,
sandbox_mode: true,
allow_external_tools: false,
..Default::default()
};
let summary = config.get_summary();
assert!(summary.contains("READONLY MODE"));
assert!(summary.contains("SANDBOX MODE"));
assert!(summary.contains("No external tools"));
}
#[test]
fn test_combined_security_modes() {
let mut whitelist = HashSet::new();
whitelist.insert("open_document".to_string());
whitelist.insert("extract_text".to_string());
let config = SecurityConfig {
readonly_mode: true,
sandbox_mode: true,
command_whitelist: Some(whitelist),
command_blacklist: None,
allow_external_tools: false,
allow_network: false,
max_document_size: 1024,
..Default::default()
};
// Should only allow whitelisted readonly commands
assert!(config.is_command_allowed("open_document"));
assert!(config.is_command_allowed("extract_text"));
// Should block everything else
assert!(!config.is_command_allowed("get_metadata")); // Not in whitelist
assert!(!config.is_command_allowed("add_paragraph")); // Not readonly
assert!(!config.is_command_allowed("convert_to_pdf")); // External tools disabled
}
#[test]
fn test_recursive_path_argument_checking() {
let config = SecurityConfig {
sandbox_mode: true,
..Default::default()
};
let middleware = SecurityMiddleware::new(config);
// Complex nested arguments with paths
let nested_args = json!({
"document_id": "test",
"options": {
"output_path": "/home/user/bad/path.docx",
"settings": {
"temp_file": "/tmp/safe/path.tmp"
}
},
"files": [
"/home/user/another/bad/path.docx",
"/tmp/docx-mcp/safe/path.docx"
]
});
let result = middleware.check_command("some_command", &nested_args);
assert!(matches!(result, Err(SecurityError::PathNotAllowed(_))));
}
#[test]
fn test_security_error_messages() {
let error = SecurityError::CommandNotAllowed("dangerous_command".to_string());
assert!(error.to_string().contains("dangerous_command"));
let error = SecurityError::PathNotAllowed("/bad/path".to_string());
assert!(error.to_string().contains("/bad/path"));
let error = SecurityError::FileTooLarge { size: 2000, max_size: 1000 };
assert!(error.to_string().contains("2000"));
assert!(error.to_string().contains("1000"));
}
#[fixture]
fn readonly_config() -> SecurityConfig {
SecurityConfig {
readonly_mode: true,
command_blacklist: None,
..Default::default()
}
}
#[fixture]
fn sandbox_config() -> SecurityConfig {
SecurityConfig {
sandbox_mode: true,
allow_external_tools: false,
allow_network: false,
command_blacklist: None,
..Default::default()
}
}
#[fixture]
fn restrictive_config() -> SecurityConfig {
let mut whitelist = HashSet::new();
whitelist.insert("open_document".to_string());
whitelist.insert("extract_text".to_string());
SecurityConfig {
readonly_mode: true,
sandbox_mode: true,
command_whitelist: Some(whitelist),
command_blacklist: None,
max_document_size: 1024 * 1024, // 1MB
max_open_documents: 5,
allow_external_tools: false,
allow_network: false,
}
}
#[rstest]
#[case("open_document", true)]
#[case("extract_text", true)]
#[case("get_metadata", true)]
#[case("create_document", false)]
#[case("add_paragraph", false)]
#[case("save_document", false)]
fn test_readonly_mode_commands(readonly_config: SecurityConfig, #[case] command: &str, #[case] expected: bool) {
assert_eq!(readonly_config.is_command_allowed(command), expected);
}
#[rstest]
#[case("open_document", true)]
#[case("extract_text", true)]
#[case("add_paragraph", false)] // Not in whitelist
#[case("get_metadata", false)] // Not in whitelist
fn test_restrictive_mode_commands(restrictive_config: SecurityConfig, #[case] command: &str, #[case] expected: bool) {
assert_eq!(restrictive_config.is_command_allowed(command), expected);
}