Building a Robust GitHub PR Agent with FastMCP and FastAPI This post will guide you through creating a powerful GitHub Pull Request (PR) agent. This agent will consist of two main components:

An MCP Server in Python (using FastMCP): This server will encapsulate the logic for interacting with GitHub PRs, exposing these capabilities as “tools” that can be discovered and used by AI models (like those from Anthropic, Google, or OpenAI through an adapter). A REST API in Python (using FastAPI): This API will serve as an intermediary, allowing any client (including an LLM agent configured to use HTTP tools, or a simple web application) to access the functionalities of your MCP server. We’ll start with the MCP server, defining tools for:

Getting a PR diff. Updating a PR description. NEW: Adding a review comment to a PR. Then, we’ll build the FastAPI application to wrap these MCP capabilities and expose them over HTTP.

Part 1: The FastMCP Server The FastMCP server will be responsible for directly interacting with the GitHub API. It will expose a set of well-defined “tools” for an LLM to use.

Project Structure Let’s set up our project directory:

github_mcp_agent/ ├── tools/ │ ├── github_utils.py # Core GitHub API interaction functions ├── mcp_server.py # Your FastMCP server definition ├── api_server.py # Your FastAPI application ├── .env # Environment variables (GitHub Token, etc.) └── requirements.txt # Python dependencies

  1. tools/github_utils.py This file contains the actual GitHub API calls. We’ll use the requests library for simplicity.
 
# github_mcp_agent/tools/github_utils.py
import os
import requests
 
def _parse_pr_link(pr_link: str):
    """Parses a GitHub PR link to extract owner, repo, and PR number."""
    # Example: https://github.com/owner/repo/pull/123
    parts = pr_link.strip('/').split('/')
    if len(parts) < 7 or parts[2] != 'github.com' or parts[5] != 'pull':
        raise ValueError(f"Invalid GitHub PR link format: {pr_link}")
    owner = parts[3]
    repo = parts[4]
    pr_number = int(parts[6])
    return owner, repo, pr_number
 
def _get_github_headers():
    """Returns standard GitHub API headers with Authorization token."""
    github_token = os.getenv("GITHUB_TOKEN")
    if not github_token:
        raise ValueError("GITHUB_TOKEN environment variable not set. Please set it for GitHub API access.")
    return {
        "Authorization": f"token {github_token}",
        "Accept": "application/vnd.github.v3+json", # Default for most GitHub API calls
    }
 
def get_pr_diff(pr_link: str) -> str:
    """
    Fetches the complete diff content of a given GitHub Pull Request.
    """
    owner, repo, pr_number = _parse_pr_link(pr_link)
    headers = _get_github_headers()
    headers["Accept"] = "application/vnd.github.v3.diff" # Request diff format
    url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}"
 
    try:
        response = requests.get(url, headers=headers)
        response.raise_for_status() # Raise an exception for HTTP errors (4xx or 5xx)
        return response.text
    except requests.exceptions.RequestException as e:
        raise RuntimeError(f"Failed to fetch PR diff for {pr_link}: {e}")
 
def update_pr_description(pr_link: str, new_description: str) -> bool:
    """
    Updates the description (body) of a given GitHub Pull Request.
    """
    owner, repo, pr_number = _parse_pr_link(pr_link)
    headers = _get_github_headers()
    url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}"
    payload = {"body": new_description}
 
    try:
        response = requests.patch(url, headers=headers, json=payload)
        response.raise_for_status()
        return True # Indicate success
    except requests.exceptions.RequestException as e:
        raise RuntimeError(f"Failed to update PR description for {pr_link}: {e}")
 
def add_pr_review_comment(pr_link: str, body: str, commit_id: str, path: str, line: int = None) -> dict:
    """
    Adds a review comment to a specific line in a file within a GitHub Pull Request.
 
    Args:
        pr_link (str): The full URL of the GitHub Pull Request.
        body (str): The text of the review comment.
        commit_id (str): The SHA of the commit to comment on. This commit must be part of the PR's head branch.
        path (str): The relative path to the file that the comment applies to.
        line (int, optional): The line number in the file where the comment should be placed.
                               If None, it becomes a file-level comment.
    Returns:
        dict: The JSON response from GitHub for the created comment.
    Raises:
        ValueError: If essential parameters are missing or invalid.
        RuntimeError: If the GitHub API call fails.
    """
    owner, repo, pr_number = _parse_pr_link(pr_link)
    headers = _get_github_headers()
    url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/comments"
 
    payload = {
        "body": body,
        "commit_id": commit_id,
        "path": path,
        "position": line # For single-line comments, position refers to the line number
                         # on the "right" side of the diff.
    }
 
    if line is None:
        # If no line is specified, it's a file-level comment.
        # Position is generally not used for file-level comments or it needs to be 0 for a non-diff comment
        # However, for 'pulls/comments' API, path and commit_id are typically tied to a diff.
        # If you truly want a non-line-specific comment on a PR, it's often better to use 'add_issue_comment'.
        # For the purpose of this tool, we'll make line optional but assume it's still a diff comment.
        # GitHub's API might require `position` even for file-level comments *within the diff context*.
        # For simplicity, we'll enforce `line` for now to ensure a clear single-line comment.
        raise ValueError("For add_pr_review_comment, 'line' must be specified for inline comments.")
    
    try:
        response = requests.post(url, headers=headers, json=payload)
        response.raise_for_status()
        return response.json()
    except requests.exceptions.RequestException as e:
        raise RuntimeError(f"Failed to add PR review comment for {pr_link}: {e}. Response: {response.text}")
    except ValueError as e:
        raise ValueError(str(e)) # Re-raise parsing errors
    except Exception as e:
        raise RuntimeError(f"An unexpected error occurred while adding PR review comment: {e}")
 
# Helper to get the latest commit SHA of a PR's head branch for commenting
def get_pr_head_commit_sha(pr_link: str) -> str:
    """
    Fetches the SHA of the HEAD commit for a given Pull Request.
    This is often needed for PR review comments.
    """
    owner, repo, pr_number = _parse_pr_link(pr_link)
    headers = _get_github_headers()
    url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}"
 
    try:
        response = requests.get(url, headers=headers)
        response.raise_for_status()
        pr_data = response.json()
        return pr_data['head']['sha']
    except requests.exceptions.RequestException as e:
        raise RuntimeError(f"Failed to fetch PR head commit SHA for {pr_link}: {e}")
    except KeyError:
        raise RuntimeError(f"Could not find head commit SHA in PR data for {pr_link}. Malformed response?")
  1. mcp_server.py This file defines your MCP server using FastMCP.
# github_mcp_agent/mcp_server.py
import os
import sys
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
 
# Load environment variables from .env file
load_dotenv()
 
# Import the GitHub utility functions
from tools.github_utils import get_pr_diff, update_pr_description, add_pr_review_comment, get_pr_head_commit_sha
 
# Initialize FastMCP server
# The name and description are visible to the LLM when it introspects capabilities.
mcp = FastMCP(
    name="GitHubPRTools",
    description="A set of tools for advanced interactions with GitHub Pull Requests, including diff retrieval, description updates, and adding review comments.",
    version="1.0.0" # Optional versioning
)
 
# --- Define Pydantic Models for Tool Arguments ---
# Pydantic models automatically generate the JSON Schema for your tool parameters,
# which is crucial for MCP's `getCapabilities` response.
 
class PRLinkArgs(BaseModel):
    pr_link: str = Field(
        description="The full URL of the GitHub Pull Request (e.g., 'https://github.com/owner/repo/pull/123')."
    )
 
class UpdatePRDescriptionArgs(PRLinkArgs):
    new_description: str = Field(
        description="The new Markdown content for the PR description. Can be an empty string to clear."
    )
 
class AddPRReviewCommentArgs(PRLinkArgs):
    body: str = Field(
        description="The text content of the review comment."
    )
    path: str = Field(
        description="The relative path to the file in the PR where the comment should be placed (e.g., 'src/main.py')."
    )
    line: int = Field(
        description="The specific line number in the file where the comment should be placed. This must be a line within the diff.",
        ge=1 # Greater than or equal to 1
    )
    # commit_id is often needed but can be implicitly fetched if the LLM isn't expected to provide it.
    # For now, let's keep it explicit if the LLM needs to resolve it.
    # We can add a helper tool later if needed.
 
# --- Define Tools using @mcp.tool() Decorator ---
 
@mcp.tool()
def get_pr_diff_tool(args: PRLinkArgs) -> str:
    """
    Retrieves the complete diff content of a GitHub Pull Request.
    This tool is useful for understanding all code changes in a PR.
    """
    print(f"MCP: get_pr_diff_tool called for PR: {args.pr_link}", file=sys.stderr) # Log to stderr for debugging
    try:
        diff_content = get_pr_diff(args.pr_link)
        return diff_content
    except Exception as e:
        # FastMCP will convert exceptions into structured errors for the client.
        raise ValueError(f"Failed to retrieve PR diff: {e}")
 
@mcp.tool()
def update_pr_description_tool(args: UpdatePRDescriptionArgs) -> dict:
    """
    Updates the main description (body) of a GitHub Pull Request.
    This is useful for adding summaries, generated reports, or other context to a PR.
    """
    print(f"MCP: update_pr_description_tool called for PR: {args.pr_link}", file=sys.stderr)
    try:
        success = update_pr_description(args.pr_link, args.new_description)
        if success:
            return {"status": "success", "message": "PR description updated successfully."}
        else:
            return {"status": "failed", "message": "Failed to update PR description for unknown reason."}
    except Exception as e:
        raise ValueError(f"Failed to update PR description: {e}")
 
@mcp.tool()
def add_pr_review_comment_tool(args: AddPRReviewCommentArgs) -> dict:
    """
    Adds an inline review comment to a specific line in a file within a GitHub Pull Request.
    Requires the full PR link, the comment body, the file path, and the specific line number.
    Note: The LLM might need to first use another tool (or have context) to get the correct commit_id and line numbers.
    For simplicity, this tool will attempt to automatically fetch the latest HEAD commit SHA.
    """
    print(f"MCP: add_pr_review_comment_tool called for PR: {args.pr_link}, path: {args.path}, line: {args.line}", file=sys.stderr)
    try:
        # Automatically fetch the latest HEAD commit SHA for the PR
        commit_id = get_pr_head_commit_sha(args.pr_link)
        if not commit_id:
            raise ValueError("Could not determine the latest commit SHA for the PR to add a comment.")
 
        comment_data = add_pr_review_comment(args.pr_link, args.body, commit_id, args.path, args.line)
        return {
            "status": "success",
            "message": "PR review comment added successfully.",
            "comment_id": comment_data.get("id"),
            "html_url": comment_data.get("html_url")
        }
    except ValueError as e:
        raise e # Re-raise argument validation errors
    except Exception as e:
        raise ValueError(f"Failed to add PR review comment: {e}")
 
# --- Main execution block for the MCP server ---
if __name__ == "__main__":
    # Check for GITHUB_TOKEN directly in the server's environment
    if not os.getenv("GITHUB_TOKEN"):
        sys.stderr.write("ERROR: GITHUB_TOKEN environment variable is not set. GitHub API calls will fail.\n")
        sys.stderr.flush()
        sys.exit(1) # Exit if essential token is missing
 
    print("Starting FastMCP GitHub PR Tools Server...", file=sys.stderr) # Log to stderr for debugging
    mcp.run() # This runs the server, listening to stdin/stdout
  1. requirements.txt (for MCP Server) mcp[cli] requests pydantic python-dotenv

  2. .env GITHUB_TOKEN=ghp_YOUR_ACTUAL_GITHUB_PERSONAL_ACCESS_TOKEN Important: Replace ghp_YOUR_ACTUAL_GITHUB_PERSONAL_ACCESS_TOKEN with your actual token. Ensure this token has the necessary permissions:

repo scope: For reading diffs, updating PR descriptions, and commenting. Specifically, pull_requests:write (if available for your token type) is ideal for updates/comments, but repo generally covers it for personal tokens. Running and Testing the MCP Server Directly You can test your MCP server using the mcp inspect tool:

Navigate to your github_mcp_agent directory in the terminal. Run:

mcp inspect stdio --command "python" --args "mcp_server.py"

You’ll get an interactive prompt. Try these commands: help: See available commands. call get_pr_diff_tool pr_link=“https://github.com/octocat/Spoon-Knife/pull/1” call update_pr_description_tool pr_link=“https://github.com/octocat/Spoon-Knife/pull/1” new_description=“This is a test description from MCP!” (Use a PR you own!) call add_pr_review_comment_tool pr_link=“https://github.com/octocat/Spoon-Knife/pull/1” path=“README.md” line=1 body=“Great work on this line!” (Again, use a PR you own, and ensure the path/line exist in its diff.)

Part 2: The FastAPI API Server Now, we’ll create a FastAPI application that acts as a wrapper around our MCP server. This allows us to expose the MCP tools over HTTP, making them accessible to any client, including LLMs that support HTTP tool use.

  1. api_server.py This FastAPI app will launch your MCP server as a subprocess and mediate communication.
# github_mcp_agent/api_server.py
import json
import os
import subprocess
import sys
import threading
import time
from queue import Queue, Empty
 
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
from dotenv import load_dotenv
 
# Load environment variables (including GITHUB_TOKEN)
load_dotenv()
 
app = FastAPI(
    title="GitHub PR Agent API",
    description="API to interact with GitHub Pull Requests via an underlying FastMCP server.",
    version="1.0.0"
)
 
# --- Pydantic Models for API Endpoints ---
# These mirror the tool arguments for clarity, but are for the HTTP API.
 
class PRLinkRequest(BaseModel):
    pr_link: str = Field(
        description="The full URL of the GitHub Pull Request (e.g., 'https://github.com/owner/repo/pull/123')."
    )
 
class UpdatePRDescriptionRequest(PRLinkRequest):
    new_description: str = Field(
        description="The new Markdown content for the PR description. Can be an empty string to clear."
    )
 
class AddPRReviewCommentRequest(PRLinkRequest):
    body: str = Field(
        description="The text content of the review comment."
    )
    path: str = Field(
        description="The relative path to the file in the PR where the comment should be placed (e.g., 'src/main.py')."
    )
    line: int = Field(
        description="The specific line number in the file where the comment should be placed. This must be a line within the diff.",
        ge=1
    )
 
# --- MCP Server Process Management ---
mcp_process = None
mcp_output_queue = Queue()
mcp_ready_event = threading.Event()
 
def enqueue_output(out, queue):
    for line in iter(out.readline, ''):
        queue.put(line)
    out.close()
 
def start_mcp_server():
    """Starts the FastMCP server as a subprocess."""
    global mcp_process
    
    # Ensure GITHUB_TOKEN is available to the subprocess
    env_vars = os.environ.copy()
    if "GITHUB_TOKEN" not in env_vars:
        print("ERROR: GITHUB_TOKEN not found in environment for MCP server.", file=sys.stderr)
        raise RuntimeError("GITHUB_TOKEN is required to start MCP server.")
 
    print("Attempting to start MCP server subprocess...", file=sys.stderr)
    try:
        # Use sys.executable to ensure the correct python interpreter is used
        mcp_process = subprocess.Popen(
            [sys.executable, "mcp_server.py"],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True, # Handles encoding automatically
            bufsize=1, # Line-buffered for stdout/stderr
            env=env_vars
        )
        print(f"MCP server subprocess started with PID: {mcp_process.pid}", file=sys.stderr)
 
        # Start threads to read stdout and stderr from the subprocess
        stdout_thread = threading.Thread(target=enqueue_output, args=(mcp_process.stdout, mcp_output_queue), daemon=True)
        stderr_thread = threading.Thread(target=enqueue_output, args=(mcp_process.stderr, sys.stderr), daemon=True)
        stdout_thread.start()
        stderr_thread.start()
 
        # Wait for the MCP server to indicate readiness (e.g., by printing a specific line)
        # This is a robust way to ensure the MCP server is ready to receive commands.
        # For FastMCP, it typically prints "Starting FastMCP GitHub PR Tools Server..." to stderr.
        # Or, we can just wait for the `getCapabilities` response as the first interaction.
        # For simplicity in this example, we'll rely on the `get_capabilities` call.
        mcp_ready_event.set() # Mark as conceptually ready for initial interaction
 
    except FileNotFoundError:
        print(f"Error: Python executable '{sys.executable}' or 'mcp_server.py' not found.", file=sys.stderr)
        raise RuntimeError("Failed to find MCP server script or Python interpreter.")
    except Exception as e:
        print(f"Error starting MCP server subprocess: {e}", file=sys.stderr)
        raise RuntimeError(f"Failed to start MCP server: {e}")
 
def _send_mcp_command(command: dict) -> dict:
    """Sends a command to the MCP server and waits for its response."""
    if not mcp_process or mcp_process.poll() is not None:
        raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="MCP server is not running or crashed.")
 
    try:
        command_str = json.dumps(command) + '\n'
        mcp_process.stdin.write(command_str)
        mcp_process.stdin.flush()
        print(f"Sent MCP command: {command_str.strip()}", file=sys.stderr)
 
        # Wait for a response from the MCP server
        # We need to read from the queue until we get a valid JSON response
        max_attempts = 10 # Try a few times
        attempt = 0
        while attempt < max_attempts:
            try:
                line = mcp_output_queue.get(timeout=5) # Wait up to 5 seconds for a line
                response = json.loads(line)
                print(f"Received MCP response: {line.strip()}", file=sys.stderr)
                return response
            except Empty:
                attempt += 1
                print(f"No response from MCP server (attempt {attempt}/{max_attempts})...", file=sys.stderr)
                # Check if the process is still alive if no response
                if mcp_process.poll() is not None:
                    raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="MCP server crashed during command execution.")
            except json.JSONDecodeError:
                # If it's not valid JSON, it might be a log line from the MCP server
                # We can print it and continue waiting for valid JSON
                print(f"Non-JSON output from MCP server (might be log): {line.strip()}", file=sys.stderr)
                attempt += 1
        
        raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail="MCP server did not respond in time.")
 
    except Exception as e:
        print(f"Error communicating with MCP server: {e}", file=sys.stderr)
        # Attempt to kill the process if an error occurs to ensure clean state for next restart
        if mcp_process and mcp_process.poll() is None:
            mcp_process.terminate()
            mcp_process.wait(timeout=5)
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Internal server communication error: {e}")
 
# --- FastAPI Lifecycle Events ---
@app.on_event("startup")
async def startup_event():
    print("FastAPI startup event triggered.", file=sys.stderr)
    start_mcp_server()
    # Wait for the MCP server to be fully ready before allowing API requests
    # A simple way is to perform a getCapabilities call to ensure it's responsive.
    try:
        print("Waiting for MCP server capabilities...", file=sys.stderr)
        capabilities = _send_mcp_command({"type": "getCapabilities"})
        if "tools" not in capabilities:
             raise RuntimeError("MCP server did not return valid capabilities.")
        print("MCP server successfully initialized and capabilities retrieved.", file=sys.stderr)
    except Exception as e:
        print(f"FATAL: MCP server initialization failed: {e}", file=sys.stderr)
        sys.exit(1) # Crash FastAPI if MCP server fails to start correctly
 
@app.on_event("shutdown")
async def shutdown_event():
    global mcp_process
    if mcp_process and mcp_process.poll() is None:
        print("Terminating MCP server subprocess...", file=sys.stderr)
        mcp_process.stdin.close() # Signal end of input
        mcp_process.terminate() # Request termination
        try:
            mcp_process.wait(timeout=5) # Wait for it to exit
        except subprocess.TimeoutExpired:
            mcp_process.kill() # Force kill if it doesn't exit
            print("MCP server subprocess forcibly killed.", file=sys.stderr)
        print("MCP server subprocess terminated.", file=sys.stderr)
 
# --- API Endpoints ---
 
@app.get("/")
async def read_root():
    """Health check endpoint."""
    return {"message": "GitHub PR Agent API is running!"}
 
@app.get("/capabilities")
async def get_mcp_capabilities():
    """
    Retrieves the capabilities (tools) exposed by the underlying MCP server.
    This can be used by an LLM agent to understand what actions it can perform.
    """
    print("API: /capabilities endpoint called.", file=sys.stderr)
    command = {"type": "getCapabilities"}
    response = _send_mcp_command(command)
    
    if response and "tools" in response:
        return {"capabilities": response["tools"]}
    else:
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not retrieve MCP server capabilities.")
 
@app.post("/tools/get_pr_diff")
async def api_get_pr_diff(request: PRLinkRequest):
    """
    API endpoint to get the diff of a GitHub Pull Request.
    """
    print(f"API: /tools/get_pr_diff called for {request.pr_link}", file=sys.stderr)
    command = {
        "type": "executeTool",
        "tool": "get_pr_diff_tool",
        "arguments": {"pr_link": request.pr_link}
    }
    mcp_response = _send_mcp_command(command)
 
    if mcp_response and mcp_response.get("result") is not None:
        return {"status": "success", "diff": mcp_response["result"]}
    elif mcp_response and mcp_response.get("error"):
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=mcp_response["error"])
    else:
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Unknown error from MCP server.")
 
@app.post("/tools/update_pr_description")
async def api_update_pr_description(request: UpdatePRDescriptionRequest):
    """
    API endpoint to update the description of a GitHub Pull Request.
    """
    print(f"API: /tools/update_pr_description called for {request.pr_link}", file=sys.stderr)
    command = {
        "type": "executeTool",
        "tool": "update_pr_description_tool",
        "arguments": {
            "pr_link": request.pr_link,
            "new_description": request.new_description
        }
    }
    mcp_response = _send_mcp_command(command)
 
    if mcp_response and mcp_response.get("result", {}).get("status") == "success":
        return {"status": "success", "message": mcp_response["result"]["message"]}
    elif mcp_response and mcp_response.get("error"):
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=mcp_response["error"])
    else:
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Unknown error from MCP server.")
 
@app.post("/tools/add_pr_review_comment")
async def api_add_pr_review_comment(request: AddPRReviewCommentRequest):
    """
    API endpoint to add a review comment to a specific line in a GitHub Pull Request.
    """
    print(f"API: /tools/add_pr_review_comment called for {request.pr_link} path={request.path} line={request.line}", file=sys.stderr)
    command = {
        "type": "executeTool",
        "tool": "add_pr_review_comment_tool",
        "arguments": {
            "pr_link": request.pr_link,
            "body": request.body,
            "path": request.path,
            "line": request.line
        }
    }
    mcp_response = _send_mcp_command(command)
 
    if mcp_response and mcp_response.get("result", {}).get("status") == "success":
        return {
            "status": "success",
            "message": mcp_response["result"]["message"],
            "comment_id": mcp_response["result"].get("comment_id"),
            "html_url": mcp_response["result"].get("html_url")
        }
    elif mcp_response and mcp_response.get("error"):
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=mcp_response["error"])
    else:
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Unknown error from MCP server.")
 
# --- Main execution block for the FastAPI server ---
if __name__ == "__main__":
    import uvicorn
    # Check for GITHUB_TOKEN
    if not os.getenv("GITHUB_TOKEN"):
        sys.stderr.write("ERROR: GITHUB_TOKEN environment variable not set. Please set it.\n")
        sys.stderr.flush()
        sys.exit(1)
 
    print("Starting FastAPI API server...", file=sys.stderr)
    uvicorn.run(app, host="0.0.0.0", port=8000)
  1. requirements.txt (for FastAPI Server) fastapi uvicorn pydantic python-dotenv requests # still needed for github_utils mcp # The FastMCP library itself, for dependency management (though we don’t directly call mcp.run() here)

  2. .env (Same as before, contains GITHUB_TOKEN)

GITHUB_TOKEN=ghp_YOUR_ACTUAL_GITHUB_PERSONAL_ACCESS_TOKEN Running the Entire System Navigate to your project root (github_mcp_agent/). Install dependencies: Bash

pip install -r requirements.txt Ensure your .env file has GITHUB_TOKEN set correctly. Run the FastAPI server: Bash

uvicorn api_server:app —reload —host 0.0.0.0 —port 8000 The —reload flag is great for development, as it restarts the server when code changes. Now, your FastAPI API server is running on http://localhost:8000. When it starts, it will automatically launch your mcp_server.py as a subprocess.

Using the API from a Client (e.g., an LLM Agent, curl, or Postman) You can now interact with your GitHub PR agent via HTTP calls.

  1. Check API Health: Bash

curl http://localhost:8000/ Expected output: {“message”:“GitHub PR Agent API is running!“}

  1. Get MCP Capabilities (Optional but useful for LLM introspection):
curl http://localhost:8000/capabilities | jq .

This will show you the structured definitions of get_pr_diff_tool, update_pr_description_tool, and add_pr_review_comment_tool that your MCP server exposes.

  1. Call get_pr_diff_tool: Bash

curl -X POST “http://localhost:8000/tools/get_pr_diff
-H “Content-Type: application/json”
-d ’{“pr_link”: “https://github.com/octocat/Spoon-Knife/pull/1”}’ | jq . Replace the PR link with a valid one you can access.

  1. Call update_pr_description_tool:
curl -X POST "http://localhost:8000/tools/update_pr_description" \
-H "Content-Type: application/json" \
-d '{
  "pr_link": "https://github.com/octocat/Spoon-Knife/pull/1",
  "new_description": "This PR description was updated by the LLM agent via API at '"$(date)"'"
}' | jq .

CRITICAL: Use a test PR you own for this, as it will modify the PR’s description.

  1. Call add_pr_review_comment_tool: This one is a bit trickier because it requires path and line. You’d typically need the LLM to first get the diff (using get_pr_diff_tool) and then parse it to identify a path and line where a comment makes sense.

For testing, let’s assume README.md exists and line 1 is valid in PR #1 of octocat/Spoon-Knife.

curl -X POST "http://localhost:8000/tools/add_pr_review_comment" \
-H "Content-Type: application/json" \
-d '{
  "pr_link": "https://github.com/octocat/Spoon-Knife/pull/1",
  "body": "This is an automated review comment from the agent!",
  "path": "README.md",
  "line": 1
}' | jq .

CRITICAL: Again, use a test PR you own and ensure the path and line actually exist within that PR’s diff. If the commit_id you get from get_pr_head_commit_sha doesn’t match a commit that includes the file at that path and line within the diff context, GitHub’s API might fail.

How an LLM Uses This API For an LLM to use this API, it needs to be configured with “tool use” or “function calling” capabilities, and specifically with an HTTP adapter for those tools.

Most modern LLMs (OpenAI’s GPT-4o, Google’s Gemini, Anthropic’s Claude 3) allow you to describe external tools. You would provide them with:

The API Endpoint URLs: http://localhost:8000/tools/get_pr_diff, etc. The HTTP Method: POST for all these tools. The Request Body Schema (Pydantic models help here): This is essentially the properties and required fields from your BaseModel definitions (e.g., pr_link for PRLinkRequest). The Description: Crucial for the LLM to understand when to use the tool. (e.g., “Get the complete diff content of a GitHub Pull Request.“) When a user prompts the LLM (e.g., “Summarize the changes in PR X”), the LLM:

Decides to use a tool: Based on your prompt and the tool descriptions, it determines that get_pr_diff_tool is relevant. Generates tool arguments: It extracts pr_link from your prompt. Outputs a tool call: Instead of a direct text response, it outputs a structured call like call_get_pr_diff_tool(pr_link=”…”). The LLM’s Agent Runtime (your wrapper code or integrated platform’s feature): Intercepts this tool call. Translates it into an HTTP POST request to http://localhost:8000/tools/get_pr_diff with the JSON body. Sends the HTTP request. Receives the HTTP response from your FastAPI server. Passes the diff content from the API response back to the LLM. LLM continues reasoning: The LLM receives the diff content, processes it, and generates a summary or decides to call another tool (like update_pr_description_tool or add_pr_review_comment_tool) based on the diff content and the user’s original request. This architecture provides a clean separation of concerns:

MCP Server (mcp_server.py): Handles the actual GitHub API logic and exposes it in a protocol-agnostic (MCP) way. FastAPI Server (api_server.py): Provides a standard HTTP interface to those MCP capabilities, making them universally accessible. LLM Integration (external): Configured to use the FastAPI endpoints as its tools. This powerful setup allows you to build sophisticated AI agents that can interact with GitHub in a structured and extensible manner!