Last active
July 13, 2025 10:38
-
-
Save PrashamTrivedi/0f8526d393c5417bce1010b254926912 to your computer and use it in GitHub Desktop.
Improved MCP File Agent Server - FastMCP implementation with structured output for AI assistant symlink management
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env -S uv run | |
# /// script | |
# requires-python = ">=3.11" | |
# dependencies = [ | |
# "mcp>=1.0.0", | |
# "pydantic>=2.5.0", | |
# "typer>=0.9.0", | |
# ] | |
# /// | |
""" | |
MCP Server for File Agent - Creates symbolic links to make content accessible | |
to different AI coding assistants without duplicating storage. | |
This server uses FastMCP for modern, clean implementation with structured output. | |
""" | |
import os | |
import json | |
from enum import Enum | |
from pathlib import Path | |
from typing import Dict, List, Optional | |
from mcp.server.fastmcp import FastMCP | |
from pydantic import BaseModel | |
# Configuration Models | |
class Mode(str, Enum): | |
INSTRUCTIONS = "instructions" | |
PROMPT = "prompt" | |
class Assistant(str, Enum): | |
CLAUDE = "claude" | |
COPILOT = "copilot" | |
CODEX = "codex" | |
CURSOR = "cursor" | |
ROO = "roo" | |
CLINE = "cline" | |
GEMINI_CLI = "gemini" | |
class AssistantConfig(BaseModel): | |
"""Configuration mapping for AI assistant file conventions""" | |
instructions_path: str | |
prompt_path_template: Optional[str] = None | |
def get_target_path(self, mode: Mode, prompt_name: Optional[str] = None, | |
roo_mode_slug: Optional[str] = None) -> Optional[Path]: | |
"""Get the target path for a given mode and assistant""" | |
if mode == Mode.INSTRUCTIONS: | |
path_str = self.instructions_path | |
if roo_mode_slug and "{mode_slug}" in path_str: | |
path_str = path_str.replace("{mode_slug}", roo_mode_slug) | |
return Path(path_str) | |
elif mode == Mode.PROMPT and self.prompt_path_template: | |
if not prompt_name: | |
return None | |
path_str = self.prompt_path_template.replace("{prompt_name}", prompt_name) | |
if roo_mode_slug and "{mode_slug}" in path_str: | |
path_str = path_str.replace("{mode_slug}", roo_mode_slug) | |
return Path(path_str) | |
return None | |
# Response Models | |
class SymlinkResult(BaseModel): | |
"""Result of symlink creation operation""" | |
success: bool | |
message: str | |
source_path: str | |
target_path: Optional[str] = None | |
class ValidationResult(BaseModel): | |
"""Result of path validation""" | |
valid: bool | |
message: str | |
source_path: str | |
target_path: Optional[str] = None | |
class CleanupResult(BaseModel): | |
"""Result of cleanup operation""" | |
removed_count: int | |
removed_files: List[str] | |
message: str | |
class AssistantInfo(BaseModel): | |
"""Information about an assistant configuration""" | |
name: str | |
instructions_path: str | |
prompt_path_template: Optional[str] | |
supports_prompts: bool | |
# Define assistant configurations | |
ASSISTANT_CONFIGS: Dict[Assistant, AssistantConfig] = { | |
Assistant.CLAUDE: AssistantConfig( | |
instructions_path="CLAUDE.md", | |
prompt_path_template=".claude/commands/{prompt_name}.md" | |
), | |
Assistant.COPILOT: AssistantConfig( | |
instructions_path=".copilot-instructions.md", | |
prompt_path_template=".github/prompts/{prompt_name}.prompt.md" | |
), | |
Assistant.CODEX: AssistantConfig( | |
instructions_path="AGENTS.md", | |
prompt_path_template=None | |
), | |
Assistant.CURSOR: AssistantConfig( | |
instructions_path=".cursor/rules/{prompt_name}.md", | |
prompt_path_template=".cursor/rules/{prompt_name}.md" | |
), | |
Assistant.ROO: AssistantConfig( | |
instructions_path=".roo/rules/{prompt_name}.md", | |
prompt_path_template=".roo/rules{mode_slug}/{prompt_name}.md" | |
), | |
Assistant.CLINE: AssistantConfig( | |
instructions_path=".clinerules", | |
prompt_path_template=".clinerules/{prompt_name}" | |
), | |
Assistant.GEMINI_CLI: AssistantConfig( | |
instructions_path="GEMINI.md", | |
prompt_path_template=None | |
), | |
} | |
# Utility functions | |
def ensure_parent_directory(path: Path) -> None: | |
"""Ensure the parent directory of a file exists""" | |
parent = path.parent | |
if not parent.exists(): | |
parent.mkdir(parents=True, exist_ok=True) | |
def create_symlink(source: Path, target: Path) -> SymlinkResult: | |
"""Create a symbolic link from source to target""" | |
try: | |
ensure_parent_directory(target) | |
if target.exists() or target.is_symlink(): | |
target.unlink() | |
os.symlink(source.resolve(), target) | |
return SymlinkResult( | |
success=True, | |
message=f"Created symbolic link: {target} -> {source}", | |
source_path=str(source), | |
target_path=str(target) | |
) | |
except Exception as e: | |
return SymlinkResult( | |
success=False, | |
message=f"Failed to create symbolic link: {e}", | |
source_path=str(source), | |
target_path=str(target) if target else None | |
) | |
def validate_paths(source: Path, target: Path) -> ValidationResult: | |
"""Validate source and target paths""" | |
if not source.exists(): | |
return ValidationResult( | |
valid=False, | |
message=f"Source file does not exist: {source}", | |
source_path=str(source), | |
target_path=str(target) | |
) | |
if target.exists() and target.samefile(source): | |
return ValidationResult( | |
valid=False, | |
message="Source and target are the same file", | |
source_path=str(source), | |
target_path=str(target) | |
) | |
return ValidationResult( | |
valid=True, | |
message="Paths are valid", | |
source_path=str(source), | |
target_path=str(target) | |
) | |
def cleanup_broken_links(directory: Path) -> CleanupResult: | |
"""Remove broken symlinks in a directory""" | |
removed = [] | |
if not directory.exists(): | |
return CleanupResult( | |
removed_count=0, | |
removed_files=[], | |
message=f"Directory does not exist: {directory}" | |
) | |
for item in directory.rglob("*"): | |
if item.is_symlink() and not item.exists(): | |
try: | |
item.unlink() | |
removed.append(str(item)) | |
except Exception: | |
pass | |
return CleanupResult( | |
removed_count=len(removed), | |
removed_files=removed, | |
message=f"Removed {len(removed)} broken symlinks" | |
) | |
# Initialize FastMCP server | |
mcp = FastMCP("file-agent") | |
@mcp.tool() | |
def create_symlink_tool( | |
source: str, | |
assistant: str, | |
mode: str, | |
prompt_name: Optional[str] = None, | |
roo_mode_slug: Optional[str] = None | |
) -> SymlinkResult: | |
"""Create symbolic links for assistant files""" | |
try: | |
source_path = Path(source) | |
assistant_enum = Assistant(assistant) | |
mode_enum = Mode(mode) | |
config = ASSISTANT_CONFIGS.get(assistant_enum) | |
if not config: | |
return SymlinkResult( | |
success=False, | |
message=f"Configuration not found for assistant: {assistant}", | |
source_path=source | |
) | |
if mode_enum == Mode.PROMPT and not prompt_name: | |
prompt_name = source_path.stem | |
target_path = config.get_target_path(mode_enum, prompt_name, roo_mode_slug) | |
if not target_path: | |
return SymlinkResult( | |
success=False, | |
message=f"Unable to determine target path for {assistant} with mode {mode}", | |
source_path=source | |
) | |
return create_symlink(source_path, target_path) | |
except ValueError as e: | |
return SymlinkResult( | |
success=False, | |
message=f"Invalid parameter: {e}", | |
source_path=source | |
) | |
except Exception as e: | |
return SymlinkResult( | |
success=False, | |
message=f"Unexpected error: {e}", | |
source_path=source | |
) | |
@mcp.tool() | |
def list_assistants() -> List[AssistantInfo]: | |
"""List available AI assistant configurations""" | |
return [ | |
AssistantInfo( | |
name=assistant.value, | |
instructions_path=config.instructions_path, | |
prompt_path_template=config.prompt_path_template, | |
supports_prompts=config.prompt_path_template is not None | |
) | |
for assistant, config in ASSISTANT_CONFIGS.items() | |
] | |
@mcp.tool() | |
def get_assistant_config(assistant: str) -> AssistantInfo: | |
"""Get configuration for specific assistant""" | |
try: | |
assistant_enum = Assistant(assistant) | |
config = ASSISTANT_CONFIGS.get(assistant_enum) | |
if not config: | |
raise ValueError(f"Configuration not found for assistant: {assistant}") | |
return AssistantInfo( | |
name=assistant_enum.value, | |
instructions_path=config.instructions_path, | |
prompt_path_template=config.prompt_path_template, | |
supports_prompts=config.prompt_path_template is not None | |
) | |
except ValueError as e: | |
raise ValueError(f"Invalid assistant: {e}") | |
@mcp.tool() | |
def validate_paths_tool( | |
source: str, | |
assistant: str, | |
mode: str, | |
prompt_name: Optional[str] = None, | |
roo_mode_slug: Optional[str] = None | |
) -> ValidationResult: | |
"""Validate source/target paths before linking""" | |
try: | |
source_path = Path(source) | |
assistant_enum = Assistant(assistant) | |
mode_enum = Mode(mode) | |
config = ASSISTANT_CONFIGS.get(assistant_enum) | |
if not config: | |
return ValidationResult( | |
valid=False, | |
message=f"Configuration not found for assistant: {assistant}", | |
source_path=source | |
) | |
if mode_enum == Mode.PROMPT and not prompt_name: | |
prompt_name = source_path.stem | |
target_path = config.get_target_path(mode_enum, prompt_name, roo_mode_slug) | |
if not target_path: | |
return ValidationResult( | |
valid=False, | |
message=f"Unable to determine target path for {assistant} with mode {mode}", | |
source_path=source | |
) | |
return validate_paths(source_path, target_path) | |
except ValueError as e: | |
return ValidationResult( | |
valid=False, | |
message=f"Invalid parameter: {e}", | |
source_path=source | |
) | |
except Exception as e: | |
return ValidationResult( | |
valid=False, | |
message=f"Unexpected error: {e}", | |
source_path=source | |
) | |
@mcp.tool() | |
def cleanup_links(directory: str = ".") -> CleanupResult: | |
"""Remove broken or outdated symlinks""" | |
try: | |
directory_path = Path(directory) | |
return cleanup_broken_links(directory_path) | |
except Exception as e: | |
return CleanupResult( | |
removed_count=0, | |
removed_files=[], | |
message=f"Error during cleanup: {e}" | |
) | |
@mcp.resource("config://assistant_configs") | |
def assistant_configs_resource() -> str: | |
"""Available AI assistant configurations""" | |
configs = {} | |
for assistant, config in ASSISTANT_CONFIGS.items(): | |
configs[assistant.value] = { | |
"instructions_path": config.instructions_path, | |
"prompt_path_template": config.prompt_path_template, | |
"supports_prompts": config.prompt_path_template is not None | |
} | |
return json.dumps(configs, indent=2) | |
@mcp.resource("config://path_templates") | |
def path_templates_resource() -> str: | |
"""Template patterns for each assistant""" | |
templates = {} | |
for assistant, config in ASSISTANT_CONFIGS.items(): | |
templates[assistant.value] = { | |
"instructions_example": config.instructions_path, | |
"prompt_example": config.prompt_path_template.replace("{prompt_name}", "example") if config.prompt_path_template else None, | |
"supports_roo_mode": "{mode_slug}" in (config.instructions_path + (config.prompt_path_template or "")) | |
} | |
return json.dumps(templates, indent=2) | |
@mcp.prompt() | |
def setup_assistant( | |
arguments:str | |
) -> str: | |
"""Guide user through single or batch assistant setup""" | |
return f""" | |
You are an intelligent assistant setup helper. | |
You will list current set of files, and look for existing markdown files. | |
Determine if there are any existing coding assistant files and convert them for given coding assistants. | |
<Arguments> | |
Get or infer following things from ${arguments} | |
- assistant:Name of the assistant for which we are setting things up. Can be one of the variant of {",".join(ASSISTANT_CONFIGS.keys())} | |
- SourceFile: If absent, Check one of the available assistants. This must be explicitly provided by the user, do not infer own your own | |
- Mode: Only consider if SourceFile is provided, this must be one of {",".join(Mode)} | |
- Roo Mode Slug: If assistant is roo, then consider a single word camelCase name of SoruceFile or take it from the user provided value | |
</Arguments> | |
<AvailableAssistantsToCover> | |
- If you can list resources, you will use `assistants_config_resource` resource for the complete list of assistants, | |
- If you can not list them, Call `list_assistants` tool to get available assistants. We will convert for one of the assistants, | |
- If the argument doesn't cover one of the assistant. Politely and directly decline the user request | |
</AvailableAssistantsToCover> | |
<ExistingAssistantFiles> | |
- .cursorrules: Existing instruction files for cursor | |
- CLAUDE.md: Existing instruction files for claude code | |
- .claude/commands: Existing prompt files for claude code, these are reusable commands for claude | |
- AGENTS.md: Existing instruction files for codex code | |
- .copilot-instructions.md: Existing prompt files for copilot code | |
- .github/prompts: Existing prompt files for prompt, these are reusable commands for copilot | |
- .roo/rules: Existing instruction files for roo code | |
- .clinerules: Existing instruction files for cline code | |
- GEMINI.md: Existing instruction files for gemini code | |
- Onboarding.md (or any variant of it): It is a generic instruction file, intended to cover any assistant | |
</ExistingAssistantFiles> | |
<Rules> | |
- First understand the existing assistant files and their mode. You should only check for markdown files. | |
- Amongst existing files, consider if any assistant files is symlinked to an original file (E.g. Onboarding.md->Gemini.md or CLAUDE.md->.cursorrules), and only consider original file and ignore symlinked file | |
- At every step, verify from user about the parameters to be passed for the tools. | |
- For each files, follow these rules and convert the files | |
- First, call `validate_paths_tool` to validate the path before linking, if there are errors, explain them to users | |
- Once validated, call `create_symlink_tool` with proper parameters for given coding assistant. | |
- Definition of the parameters | |
- Source: One of the existing assistant files or Source File from argument if provided. | |
- Assistant: assistant from argument | |
- Mode: Inferred from existing assistant files, or if sourceFile provided, consider mode from argument | |
- prompt_name: If mode is 'prompt', use the source file's stem as the prompt_name. | |
- roo_mode_slug: Only applicable if assistant is roo, roo_mode_slug from argument | |
</Rules> | |
""" | |
if __name__ == "__main__": | |
mcp.run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment