From 2d53f8917243afbb0bf5d2f03a3dc4667efa8062 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 21 Feb 2026 22:29:33 +0000 Subject: [PATCH] Refactor MCP server structure and add AI commit tool - Restructure src/ into package src/crazy_mcp with modular tools - Add jj.py and sandbox.py tool modules for jujutsu and sandbox management - Remove circular dependency on crazy-mcp from pyproject.toml - Fix async get_state call in PMOCrazyCoder.py increment tool - Add bin/jj-ai-commit.sh for AI-generated commit messages from diffs - Update entrypoint to copy all bin scripts and create config directory --- Dockerfile | 3 + bin/jj-ai-commit.sh | 16 +++ pyproject.toml | 1 - src/PMOCrazyCoder.py | 5 +- src/crazy_mcp/__init__.py | 3 + src/crazy_mcp/tools/__init__.py | 2 + src/crazy_mcp/tools/jj.py | 33 +++++ src/crazy_mcp/tools/sandbox.py | 89 +++++++++++++ toto.diff | 225 ++++++++++++++++++++++++++++++++ 9 files changed, 373 insertions(+), 4 deletions(-) create mode 100755 bin/jj-ai-commit.sh create mode 100644 src/crazy_mcp/__init__.py create mode 100644 src/crazy_mcp/tools/__init__.py create mode 100644 src/crazy_mcp/tools/jj.py create mode 100644 src/crazy_mcp/tools/sandbox.py create mode 100644 toto.diff diff --git a/Dockerfile b/Dockerfile index 3dd20c7..1edb5b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,6 +61,9 @@ RUN groupadd --gid $USER_GID $USERNAME && \ COPY docker-entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh + +COPY bin/* /usr/local/bin/ +RUN chmod +x /usr/local/bin/* RUN mkdir -p /home/${USERNAME}/.config \ && chown -R ${USERNAME}:${USERNAME} /home/${USERNAME}/.config COPY --chown=${USERNAME}:${USERNAME} opencode /home/${USERNAME}/.config/opencode diff --git a/bin/jj-ai-commit.sh b/bin/jj-ai-commit.sh new file mode 100755 index 0000000..aae5c55 --- /dev/null +++ b/bin/jj-ai-commit.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +LOCAL_LLM_API=${LOCAL_LLM_API:-http://host.docker.internal:1248/v1} + +DIFF=$(jj diff) +PROMPT='Analyse ce diff et réponds UNIQUEMENT avec un objet JSON valide contenant "title" (string) et "message" (string) pour le message de commit. Pas de texte avant ou après le JSON.' + +CONTENT="${PROMPT} +${DIFF}" + +REQUEST=$(jq -n --arg content "$CONTENT" '{"model": "qwen3-coder", "messages": [{"role": "user", "content": $content}]}') + +curl -s -X POST \ + -H "Content-Type: application/json" \ + -d "$REQUEST" \ + "$LOCAL_LLM_API/chat/completions" | jq -r '.choices[0].message.content' | jq -r '.title + "\n\n" + .message' diff --git a/pyproject.toml b/pyproject.toml index 4d15816..cd40d11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,6 @@ version = "0.1.0" description = "A crazy MCP server" requires-python = ">=3.10" dependencies = [ - "crazy-mcp>=0.1.0", ] [project.optional-dependencies] diff --git a/src/PMOCrazyCoder.py b/src/PMOCrazyCoder.py index 4a10aa6..fd9bf4a 100644 --- a/src/PMOCrazyCoder.py +++ b/src/PMOCrazyCoder.py @@ -22,9 +22,8 @@ def additionner(a: float, b: float) -> float: @mcp.tool async def increment(ctx: Context) -> tuple[int, str]: - count = ctx.get_state("count") or 0 - ctx.set_state("count", count + 1) - return count + 1, ctx.session_id + count = await ctx.get_state("count") or 0 + return int(count) + 1, ctx.session_id # Lancement du serveur MCP diff --git a/src/crazy_mcp/__init__.py b/src/crazy_mcp/__init__.py new file mode 100644 index 0000000..bda4dfa --- /dev/null +++ b/src/crazy_mcp/__init__.py @@ -0,0 +1,3 @@ +from fastmcp import Context, FastMCP + +mcp = FastMCP("PMO Crazy coder") diff --git a/src/crazy_mcp/tools/__init__.py b/src/crazy_mcp/tools/__init__.py new file mode 100644 index 0000000..23a3da5 --- /dev/null +++ b/src/crazy_mcp/tools/__init__.py @@ -0,0 +1,2 @@ +from .jj import * +from .sandbox import * diff --git a/src/crazy_mcp/tools/jj.py b/src/crazy_mcp/tools/jj.py new file mode 100644 index 0000000..f6bbed9 --- /dev/null +++ b/src/crazy_mcp/tools/jj.py @@ -0,0 +1,33 @@ +from crazy_mcp import Context, mcp +from jujutsu import jj_log +from sandbox import get_active_project, get_project_path + + +@mcp.tool(description="Get the commit history for a project") +def jj_log_tool(project_name: str | None = None, ctx: Context = None) -> dict: + """ + Get the commit history for a project. + + Args: + project_name (str, optional): The name of the project. If None, uses the active project. + ctx (Context): The MCP context object containing session information + + Returns: + dict: The commit history as JSON structure + """ + # If no project name is provided, try to get the active project + if project_name is None: + if ctx is None: + raise ValueError("ctx is required when project_name is None") + + project_info = get_active_project(ctx.session_id) + if project_info is None: + raise ValueError("No active project set and no project name provided") + project_path = project_info[1] + else: + project_path = get_project_path(project_name) + if project_path is None: + raise ValueError(f"Project '{project_name}' not found") + + # Call the jj_log function from jujutsu module + return jj_log(project_path) diff --git a/src/crazy_mcp/tools/sandbox.py b/src/crazy_mcp/tools/sandbox.py new file mode 100644 index 0000000..68da135 --- /dev/null +++ b/src/crazy_mcp/tools/sandbox.py @@ -0,0 +1,89 @@ +from crazy_mcp import Context, mcp +from sandbox import ( + get_active_project, + install_project, + remove_project, + sandboxed_project_list, + set_active_project, +) + + +@mcp.tool( + description="Clone a Git repository into the sandbox. Note: Access to the cloned project is only possible through MCP tools of this server. WARNING: This operation creates a sandboxed environment that prevents direct filesystem access to the project contents. Any attempt to access project files outside of the MCP tools will be blocked by security restrictions." +) +def install_project_tool(url: str, project_name: str = None) -> str: + """ + Install a Git project into the sandbox environment. + + Args: + url: The Git repository URL to clone + project_name: Optional project name (if None, derived from URL) + + Returns: + str: Confirmation message with project name + """ + project_name = install_project(url, project_name) + return f"Project '{project_name}' installed successfully in sandbox" + + +@mcp.tool( + description="Remove a project from the sandbox. Note: Access to the cloned project is only possible through MCP tools of this server. WARNING: This operation removes the entire project sandboxed environment and all associated security restrictions will be enforced." +) +def remove_project_tool(project_name: str) -> str: + """ + Remove a project from the sandbox environment. + + Args: + project_name (str): The name of the project to remove + + Returns: + str: Confirmation message with project name + """ + success = remove_project(project_name) + if success: + return f"Project '{project_name}' removed successfully from sandbox" + else: + return f"Failed to remove project '{project_name}' from sandbox" + + +@mcp.tool( + description="List all projects available in the sandbox. Note: Direct filesystem access to project contents is forbidden. All project interactions must be done through MCP tools." +) +def list_sandboxed_projects() -> list: + """ + Get a list of all projects available in the sandbox. + + Returns: + list: A list of project names + """ + return sandboxed_project_list() + + +@mcp.tool(description="Set the active project for a given session ID") +def set_active_project_tool(project_name: str, ctx: Context) -> bool: + """ + Set the active project for a given session ID. + + Args: + project_name (str): The name of the project to set as active + ctx (Context): The MCP context object containing session information + + Returns: + bool: True if successful, False if the project doesn't exist + """ + return set_active_project(ctx.session_id, project_name) + + +@mcp.tool(description="Get the active project for a given session ID") +def get_active_project_tool(ctx: Context) -> tuple[str, str] | None: + """ + Get the active project for a given session ID. + + Args: + ctx (Context): The MCP context object containing session information + + Returns: + tuple: (project_name, project_path) if session exists, None otherwise + """ + project_info = get_active_project(ctx.session_id) + return project_info diff --git a/toto.diff b/toto.diff new file mode 100644 index 0000000..7eaafcf --- /dev/null +++ b/toto.diff @@ -0,0 +1,225 @@ +diff --git a/bin/jj-ai-commit.sh b/bin/jj-ai-commit.sh +new file mode 100755 +index 0000000000..b89444b55d +--- /dev/null ++++ b/bin/jj-ai-commit.sh +@@ -0,0 +1,37 @@ ++#!/usr/bin/env bash ++set -euo pipefail ++ ++if [ -z "${LOCAL_LLM_API:-}" ]; then ++ echo "Error: LOCAL_LLM_API environment variable is not set." ++ exit 1 ++fi ++ ++MODEL="qwen/qwen3-coder-next" ++ ++# Capture seulement les lignes modifiées pour limiter la taille ++diff="$(jj diff --git | grep '^[+-]' | head -n 500)" ++ ++request_json=$(jq -n \ ++ --arg model "$MODEL" \ ++ --arg diff "$diff" \ ++'{ ++ model: $model, ++ temperature: 0.2, ++ response_format: { type: "json_object" }, ++ messages: [ ++ { ++ role: "user", ++ content: ( ++ "Analyse ce diff et réponds UNIQUEMENT avec un objet JSON valide contenant \"title\" (string) et \"message\" (string). Pas de texte avant ou après le JSON.\n\n" + $diff ++ ) ++ } ++ ] ++}') ++ ++response=$(curl -s "$LOCAL_LLM_API/chat/completions" \ ++ -H "Content-Type: application/json" \ ++ -H "Authorization: Bearer dummy" \ ++ -d "$request_json") ++ ++# Récupérer et formater le commit message ++echo "$response" | jq -r '.choices[0].message.content' | jq -r '.title + "\n\n" + .message' +diff --git a/pyproject.toml b/pyproject.toml +index 4d15816f39..cd40d11e6a 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -4,7 +4,6 @@ + description = "A crazy MCP server" + requires-python = ">=3.10" + dependencies = [ +- "crazy-mcp>=0.1.0", + ] + + [project.optional-dependencies] +diff --git a/src/PMOCrazyCoder.py b/src/PMOCrazyCoder.py +index 4a10aa6427..fd9bf4a774 100644 +--- a/src/PMOCrazyCoder.py ++++ b/src/PMOCrazyCoder.py +@@ -22,9 +22,8 @@ + + @mcp.tool + async def increment(ctx: Context) -> tuple[int, str]: +- count = ctx.get_state("count") or 0 +- ctx.set_state("count", count + 1) +- return count + 1, ctx.session_id ++ count = await ctx.get_state("count") or 0 ++ return int(count) + 1, ctx.session_id + + + # Lancement du serveur MCP +diff --git a/src/crazy_mcp/__init__.py b/src/crazy_mcp/__init__.py +new file mode 100644 +index 0000000000..bda4dfa2ca +--- /dev/null ++++ b/src/crazy_mcp/__init__.py +@@ -0,0 +1,3 @@ ++from fastmcp import Context, FastMCP ++ ++mcp = FastMCP("PMO Crazy coder") +diff --git a/src/crazy_mcp/tools/__init__.py b/src/crazy_mcp/tools/__init__.py +new file mode 100644 +index 0000000000..23a3da5bd9 +--- /dev/null ++++ b/src/crazy_mcp/tools/__init__.py +@@ -0,0 +1,2 @@ ++from .jj import * ++from .sandbox import * +diff --git a/src/crazy_mcp/tools/jj.py b/src/crazy_mcp/tools/jj.py +new file mode 100644 +index 0000000000..f6bbed95c8 +--- /dev/null ++++ b/src/crazy_mcp/tools/jj.py +@@ -0,0 +1,33 @@ ++from crazy_mcp import Context, mcp ++from jujutsu import jj_log ++from sandbox import get_active_project, get_project_path ++ ++ ++@mcp.tool(description="Get the commit history for a project") ++def jj_log_tool(project_name: str | None = None, ctx: Context = None) -> dict: ++ """ ++ Get the commit history for a project. ++ ++ Args: ++ project_name (str, optional): The name of the project. If None, uses the active project. ++ ctx (Context): The MCP context object containing session information ++ ++ Returns: ++ dict: The commit history as JSON structure ++ """ ++ # If no project name is provided, try to get the active project ++ if project_name is None: ++ if ctx is None: ++ raise ValueError("ctx is required when project_name is None") ++ ++ project_info = get_active_project(ctx.session_id) ++ if project_info is None: ++ raise ValueError("No active project set and no project name provided") ++ project_path = project_info[1] ++ else: ++ project_path = get_project_path(project_name) ++ if project_path is None: ++ raise ValueError(f"Project '{project_name}' not found") ++ ++ # Call the jj_log function from jujutsu module ++ return jj_log(project_path) +diff --git a/src/crazy_mcp/tools/sandbox.py b/src/crazy_mcp/tools/sandbox.py +new file mode 100644 +index 0000000000..68da135db1 +--- /dev/null ++++ b/src/crazy_mcp/tools/sandbox.py +@@ -0,0 +1,89 @@ ++from crazy_mcp import Context, mcp ++from sandbox import ( ++ get_active_project, ++ install_project, ++ remove_project, ++ sandboxed_project_list, ++ set_active_project, ++) ++ ++ ++@mcp.tool( ++ description="Clone a Git repository into the sandbox. Note: Access to the cloned project is only possible through MCP tools of this server. WARNING: This operation creates a sandboxed environment that prevents direct filesystem access to the project contents. Any attempt to access project files outside of the MCP tools will be blocked by security restrictions." ++) ++def install_project_tool(url: str, project_name: str = None) -> str: ++ """ ++ Install a Git project into the sandbox environment. ++ ++ Args: ++ url: The Git repository URL to clone ++ project_name: Optional project name (if None, derived from URL) ++ ++ Returns: ++ str: Confirmation message with project name ++ """ ++ project_name = install_project(url, project_name) ++ return f"Project '{project_name}' installed successfully in sandbox" ++ ++ ++@mcp.tool( ++ description="Remove a project from the sandbox. Note: Access to the cloned project is only possible through MCP tools of this server. WARNING: This operation removes the entire project sandboxed environment and all associated security restrictions will be enforced." ++) ++def remove_project_tool(project_name: str) -> str: ++ """ ++ Remove a project from the sandbox environment. ++ ++ Args: ++ project_name (str): The name of the project to remove ++ ++ Returns: ++ str: Confirmation message with project name ++ """ ++ success = remove_project(project_name) ++ if success: ++ return f"Project '{project_name}' removed successfully from sandbox" ++ else: ++ return f"Failed to remove project '{project_name}' from sandbox" ++ ++ ++@mcp.tool( ++ description="List all projects available in the sandbox. Note: Direct filesystem access to project contents is forbidden. All project interactions must be done through MCP tools." ++) ++def list_sandboxed_projects() -> list: ++ """ ++ Get a list of all projects available in the sandbox. ++ ++ Returns: ++ list: A list of project names ++ """ ++ return sandboxed_project_list() ++ ++ ++@mcp.tool(description="Set the active project for a given session ID") ++def set_active_project_tool(project_name: str, ctx: Context) -> bool: ++ """ ++ Set the active project for a given session ID. ++ ++ Args: ++ project_name (str): The name of the project to set as active ++ ctx (Context): The MCP context object containing session information ++ ++ Returns: ++ bool: True if successful, False if the project doesn't exist ++ """ ++ return set_active_project(ctx.session_id, project_name) ++ ++ ++@mcp.tool(description="Get the active project for a given session ID") ++def get_active_project_tool(ctx: Context) -> tuple[str, str] | None: ++ """ ++ Get the active project for a given session ID. ++ ++ Args: ++ ctx (Context): The MCP context object containing session information ++ ++ Returns: ++ tuple: (project_name, project_path) if session exists, None otherwise ++ """ ++ project_info = get_active_project(ctx.session_id) ++ return project_info +diff --git a/toto.diff b/toto.diff +new file mode 100644 +index 0000000000..e69de29bb2