Skip to content

Instantly share code, notes, and snippets.

@PrashamTrivedi
Last active July 13, 2025 10:38
Show Gist options
  • Save PrashamTrivedi/0f8526d393c5417bce1010b254926912 to your computer and use it in GitHub Desktop.
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
#!/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