From 4f317d9ff524d1c52b613aa956b545806b433485 Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Mon, 18 Aug 2025 20:41:55 +0300 Subject: [PATCH] Add document and version management tools Extract document management functionality into focused tools: - create_document: Create new documents with metadata - list_documents: List all documents in a project - get_document: Retrieve specific document details - update_document: Modify existing documents - delete_document: Remove documents from projects Extract version control functionality: - create_version: Create immutable snapshots - list_versions: View version history - get_version: Retrieve specific version content - restore_version: Rollback to previous versions Includes improved documentation and error messages based on testing. --- .../mcp_server/features/documents/__init__.py | 12 + .../features/documents/document_tools.py | 298 +++++++++++++++++ .../features/documents/version_tools.py | 307 ++++++++++++++++++ 3 files changed, 617 insertions(+) create mode 100644 python/src/mcp_server/features/documents/__init__.py create mode 100644 python/src/mcp_server/features/documents/document_tools.py create mode 100644 python/src/mcp_server/features/documents/version_tools.py diff --git a/python/src/mcp_server/features/documents/__init__.py b/python/src/mcp_server/features/documents/__init__.py new file mode 100644 index 0000000..7b5a6c3 --- /dev/null +++ b/python/src/mcp_server/features/documents/__init__.py @@ -0,0 +1,12 @@ +""" +Document and version management tools for Archon MCP Server. + +This module provides separate tools for document operations: +- create_document, list_documents, get_document, update_document, delete_document +- create_version, list_versions, get_version, restore_version +""" + +from .document_tools import register_document_tools +from .version_tools import register_version_tools + +__all__ = ["register_document_tools", "register_version_tools"] diff --git a/python/src/mcp_server/features/documents/document_tools.py b/python/src/mcp_server/features/documents/document_tools.py new file mode 100644 index 0000000..5328be8 --- /dev/null +++ b/python/src/mcp_server/features/documents/document_tools.py @@ -0,0 +1,298 @@ +""" +Simple document management tools for Archon MCP Server. + +Provides separate, focused tools for each document operation. +Supports various document types including specs, designs, notes, and PRPs. +""" + +import json +import logging +from typing import Any, Optional, Dict, List +from urllib.parse import urljoin + +import httpx +from mcp.server.fastmcp import Context, FastMCP + +from src.server.config.service_discovery import get_api_url + +logger = logging.getLogger(__name__) + + +def register_document_tools(mcp: FastMCP): + """Register individual document management tools with the MCP server.""" + + @mcp.tool() + async def create_document( + ctx: Context, + project_id: str, + title: str, + document_type: str, + content: Optional[Dict[str, Any]] = None, + tags: Optional[List[str]] = None, + author: Optional[str] = None, + ) -> str: + """ + Create a new document with automatic versioning. + + Args: + project_id: Project UUID (required) + title: Document title (required) + document_type: Type of document. Common types: + - "spec": Technical specifications + - "design": Design documents + - "note": General notes + - "prp": Product requirement prompts + - "api": API documentation + - "guide": User guides + content: Document content as structured JSON (optional). + Can be any JSON structure that fits your needs. + tags: List of tags for categorization (e.g., ["backend", "auth"]) + author: Document author name (optional) + + Returns: + JSON with document details: + { + "success": true, + "document": {...}, + "document_id": "doc-123", + "message": "Document created successfully" + } + + Examples: + # Create API specification + create_document( + project_id="550e8400-e29b-41d4-a716-446655440000", + title="REST API Specification", + document_type="spec", + content={ + "endpoints": [ + {"path": "/users", "method": "GET", "description": "List users"}, + {"path": "/users/{id}", "method": "GET", "description": "Get user"} + ], + "authentication": "Bearer token", + "version": "1.0.0" + }, + tags=["api", "backend"], + author="API Team" + ) + + # Create design document + create_document( + project_id="550e8400-e29b-41d4-a716-446655440000", + title="Authentication Flow Design", + document_type="design", + content={ + "overview": "OAuth2 implementation design", + "components": ["AuthProvider", "TokenManager", "UserSession"], + "flow": {"step1": "Redirect to provider", "step2": "Exchange code"} + } + ) + """ + try: + api_url = get_api_url() + timeout = httpx.Timeout(30.0, connect=5.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post( + urljoin(api_url, f"/api/projects/{project_id}/docs"), + json={ + "document_type": document_type, + "title": title, + "content": content or {}, + "tags": tags, + "author": author, + }, + ) + + if response.status_code == 200: + result = response.json() + return json.dumps({ + "success": True, + "document": result.get("document"), + "document_id": result.get("document", {}).get("id"), + "message": result.get("message", "Document created successfully"), + }) + else: + error_detail = response.text + return json.dumps({"success": False, "error": error_detail}) + + except Exception as e: + logger.error(f"Error creating document: {e}") + return json.dumps({"success": False, "error": str(e)}) + + @mcp.tool() + async def list_documents(ctx: Context, project_id: str) -> str: + """ + List all documents for a project. + + Args: + project_id: Project UUID (required) + + Returns: + JSON array of documents + + Example: + list_documents(project_id="uuid") + """ + try: + api_url = get_api_url() + timeout = httpx.Timeout(30.0, connect=5.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get(urljoin(api_url, f"/api/projects/{project_id}/docs")) + + if response.status_code == 200: + result = response.json() + return json.dumps({ + "success": True, + "documents": result.get("documents", []), + "count": len(result.get("documents", [])), + }) + else: + return json.dumps({ + "success": False, + "error": f"HTTP {response.status_code}: {response.text}", + }) + + except Exception as e: + logger.error(f"Error listing documents: {e}") + return json.dumps({"success": False, "error": str(e)}) + + @mcp.tool() + async def get_document(ctx: Context, project_id: str, doc_id: str) -> str: + """ + Get detailed information about a specific document. + + Args: + project_id: Project UUID (required) + doc_id: Document UUID (required) + + Returns: + JSON with complete document details + + Example: + get_document(project_id="uuid", doc_id="doc-uuid") + """ + try: + api_url = get_api_url() + timeout = httpx.Timeout(30.0, connect=5.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get( + urljoin(api_url, f"/api/projects/{project_id}/docs/{doc_id}") + ) + + if response.status_code == 200: + document = response.json() + return json.dumps({"success": True, "document": document}) + elif response.status_code == 404: + return json.dumps({"success": False, "error": f"Document {doc_id} not found"}) + else: + return json.dumps({"success": False, "error": "Failed to get document"}) + + except Exception as e: + logger.error(f"Error getting document: {e}") + return json.dumps({"success": False, "error": str(e)}) + + @mcp.tool() + async def update_document( + ctx: Context, + project_id: str, + doc_id: str, + title: Optional[str] = None, + content: Optional[Dict[str, Any]] = None, + tags: Optional[List[str]] = None, + author: Optional[str] = None, + ) -> str: + """ + Update a document's properties. + + Args: + project_id: Project UUID (required) + doc_id: Document UUID (required) + title: New document title (optional) + content: New document content (optional) + tags: New tags list (optional) + author: New author (optional) + + Returns: + JSON with updated document details + + Example: + update_document(project_id="uuid", doc_id="doc-uuid", title="New Title", + content={"updated": "content"}) + """ + try: + api_url = get_api_url() + timeout = httpx.Timeout(30.0, connect=5.0) + + # Build update fields + update_fields = {} + if title is not None: + update_fields["title"] = title + if content is not None: + update_fields["content"] = content + if tags is not None: + update_fields["tags"] = tags + if author is not None: + update_fields["author"] = author + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.put( + urljoin(api_url, f"/api/projects/{project_id}/docs/{doc_id}"), + json=update_fields, + ) + + if response.status_code == 200: + result = response.json() + return json.dumps({ + "success": True, + "document": result.get("document"), + "message": result.get("message", "Document updated successfully"), + }) + else: + error_detail = response.text + return json.dumps({"success": False, "error": error_detail}) + + except Exception as e: + logger.error(f"Error updating document: {e}") + return json.dumps({"success": False, "error": str(e)}) + + @mcp.tool() + async def delete_document(ctx: Context, project_id: str, doc_id: str) -> str: + """ + Delete a document. + + Args: + project_id: Project UUID (required) + doc_id: Document UUID (required) + + Returns: + JSON confirmation of deletion + + Example: + delete_document(project_id="uuid", doc_id="doc-uuid") + """ + try: + api_url = get_api_url() + timeout = httpx.Timeout(30.0, connect=5.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.delete( + urljoin(api_url, f"/api/projects/{project_id}/docs/{doc_id}") + ) + + if response.status_code == 200: + result = response.json() + return json.dumps({ + "success": True, + "message": result.get("message", f"Document {doc_id} deleted successfully"), + }) + elif response.status_code == 404: + return json.dumps({"success": False, "error": f"Document {doc_id} not found"}) + else: + return json.dumps({"success": False, "error": "Failed to delete document"}) + + except Exception as e: + logger.error(f"Error deleting document: {e}") + return json.dumps({"success": False, "error": str(e)}) diff --git a/python/src/mcp_server/features/documents/version_tools.py b/python/src/mcp_server/features/documents/version_tools.py new file mode 100644 index 0000000..fd4a950 --- /dev/null +++ b/python/src/mcp_server/features/documents/version_tools.py @@ -0,0 +1,307 @@ +""" +Simple version management tools for Archon MCP Server. + +Provides separate, focused tools for version control operations. +Supports versioning of documents, features, and other project data. +""" + +import json +import logging +from typing import Any, Optional +from urllib.parse import urljoin + +import httpx +from mcp.server.fastmcp import Context, FastMCP + +from src.server.config.service_discovery import get_api_url + +logger = logging.getLogger(__name__) + + +def register_version_tools(mcp: FastMCP): + """Register individual version management tools with the MCP server.""" + + @mcp.tool() + async def create_version( + ctx: Context, + project_id: str, + field_name: str, + content: Any, + change_summary: Optional[str] = None, + document_id: Optional[str] = None, + created_by: str = "system", + ) -> str: + """ + Create a new version snapshot of project data. + + Creates an immutable snapshot that can be restored later. The content format + depends on which field_name you're versioning. + + Args: + project_id: Project UUID (e.g., "550e8400-e29b-41d4-a716-446655440000") + field_name: Which field to version - must be one of: + - "docs": For document arrays + - "features": For feature status objects + - "data": For general data objects + - "prd": For product requirement documents + content: Complete content to snapshot. Format depends on field_name: + + For "docs" - pass array of document objects: + [{"id": "doc-123", "title": "API Guide", "content": {...}}] + + For "features" - pass dictionary of features: + {"auth": {"status": "done"}, "api": {"status": "in_progress"}} + + For "data" - pass any JSON object: + {"config": {"theme": "dark"}, "settings": {...}} + + For "prd" - pass PRD object: + {"vision": "...", "features": [...], "metrics": [...]} + + change_summary: Description of what changed (e.g., "Added OAuth docs") + document_id: Optional - for versioning specific doc in docs array + created_by: Who created this version (default: "system") + + Returns: + JSON with version details: + { + "success": true, + "version": {"version_number": 3, "field_name": "docs"}, + "message": "Version created successfully" + } + + Examples: + # Version documents + create_version( + project_id="550e8400-e29b-41d4-a716-446655440000", + field_name="docs", + content=[{"id": "doc-1", "title": "Guide", "content": {"text": "..."}}], + change_summary="Updated user guide" + ) + + # Version features + create_version( + project_id="550e8400-e29b-41d4-a716-446655440000", + field_name="features", + content={"auth": {"status": "done"}, "api": {"status": "todo"}}, + change_summary="Completed authentication" + ) + """ + try: + api_url = get_api_url() + timeout = httpx.Timeout(30.0, connect=5.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post( + urljoin(api_url, f"/api/projects/{project_id}/versions"), + json={ + "field_name": field_name, + "content": content, + "change_summary": change_summary, + "change_type": "manual", + "document_id": document_id, + "created_by": created_by, + }, + ) + + if response.status_code == 200: + result = response.json() + version_num = result.get("version", {}).get("version_number") + return json.dumps({ + "success": True, + "version": result.get("version"), + "version_number": version_num, + "message": f"Version {version_num} created successfully for {field_name} field" + }) + elif response.status_code == 400: + error_text = response.text.lower() + if "invalid field_name" in error_text: + return json.dumps({ + "success": False, + "error": f"Invalid field_name '{field_name}'. Must be one of: docs, features, data, or prd" + }) + elif "content" in error_text and "required" in error_text: + return json.dumps({ + "success": False, + "error": "Content is required and cannot be empty. Provide the complete data to version." + }) + elif "format" in error_text or "type" in error_text: + if field_name == "docs": + return json.dumps({ + "success": False, + "error": f"For field_name='docs', content must be an array. Example: [{{'id': 'doc1', 'title': 'Guide', 'content': {{...}}}}]" + }) + else: + return json.dumps({ + "success": False, + "error": f"For field_name='{field_name}', content must be a dictionary/object. Example: {{'key': 'value'}}" + }) + return json.dumps({"success": False, "error": f"Bad request: {response.text}"}) + elif response.status_code == 404: + return json.dumps({ + "success": False, + "error": f"Project {project_id} not found. Please check the project ID." + }) + else: + return json.dumps({ + "success": False, + "error": f"Failed to create version (HTTP {response.status_code}): {response.text}" + }) + + except Exception as e: + logger.error(f"Error creating version: {e}") + return json.dumps({"success": False, "error": str(e)}) + + @mcp.tool() + async def list_versions( + ctx: Context, + project_id: str, + field_name: Optional[str] = None + ) -> str: + """ + List version history for a project. + + Args: + project_id: Project UUID (required) + field_name: Filter by field name - "docs", "features", "data", "prd" (optional) + + Returns: + JSON array of versions with metadata + + Example: + list_versions(project_id="uuid", field_name="docs") + """ + try: + api_url = get_api_url() + timeout = httpx.Timeout(30.0, connect=5.0) + + params = {} + if field_name: + params["field_name"] = field_name + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get( + urljoin(api_url, f"/api/projects/{project_id}/versions"), + params=params + ) + + if response.status_code == 200: + result = response.json() + return json.dumps({ + "success": True, + "versions": result.get("versions", []), + "count": len(result.get("versions", [])) + }) + else: + return json.dumps({ + "success": False, + "error": f"HTTP {response.status_code}: {response.text}" + }) + + except Exception as e: + logger.error(f"Error listing versions: {e}") + return json.dumps({"success": False, "error": str(e)}) + + @mcp.tool() + async def get_version( + ctx: Context, + project_id: str, + field_name: str, + version_number: int + ) -> str: + """ + Get detailed information about a specific version. + + Args: + project_id: Project UUID (required) + field_name: Field name - "docs", "features", "data", "prd" (required) + version_number: Version number to retrieve (required) + + Returns: + JSON with complete version details and content + + Example: + get_version(project_id="uuid", field_name="docs", version_number=3) + """ + try: + api_url = get_api_url() + timeout = httpx.Timeout(30.0, connect=5.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get( + urljoin(api_url, f"/api/projects/{project_id}/versions/{field_name}/{version_number}") + ) + + if response.status_code == 200: + result = response.json() + return json.dumps({ + "success": True, + "version": result.get("version"), + "content": result.get("content") + }) + elif response.status_code == 404: + return json.dumps({ + "success": False, + "error": f"Version {version_number} not found for field {field_name}" + }) + else: + return json.dumps({ + "success": False, + "error": "Failed to get version" + }) + + except Exception as e: + logger.error(f"Error getting version: {e}") + return json.dumps({"success": False, "error": str(e)}) + + @mcp.tool() + async def restore_version( + ctx: Context, + project_id: str, + field_name: str, + version_number: int, + restored_by: str = "system", + ) -> str: + """ + Restore a previous version. + + Args: + project_id: Project UUID (required) + field_name: Field name - "docs", "features", "data", "prd" (required) + version_number: Version number to restore (required) + restored_by: Identifier of who is restoring (optional, defaults to "system") + + Returns: + JSON confirmation of restoration + + Example: + restore_version(project_id="uuid", field_name="docs", version_number=2) + """ + try: + api_url = get_api_url() + timeout = httpx.Timeout(30.0, connect=5.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post( + urljoin(api_url, f"/api/projects/{project_id}/versions/{field_name}/{version_number}/restore"), + json={"restored_by": restored_by}, + ) + + if response.status_code == 200: + result = response.json() + return json.dumps({ + "success": True, + "message": result.get("message", f"Version {version_number} restored successfully") + }) + elif response.status_code == 404: + return json.dumps({ + "success": False, + "error": f"Version {version_number} not found for field {field_name}" + }) + else: + error_detail = response.text + return json.dumps({"success": False, "error": error_detail}) + + except Exception as e: + logger.error(f"Error restoring version: {e}") + return json.dumps({"success": False, "error": str(e)}) \ No newline at end of file