Compare commits

...

5 Commits

6 changed files with 684 additions and 11 deletions
+17
View File
@@ -0,0 +1,17 @@
# MCP Server Configuration
# This configuration file adds the MCP server to the OpenHands configuration
# Include the MCP server in the configuration
[mcp]
# List of MCP SSE servers
sse_servers = [
{
# The URL of the MCP server
url = "http://localhost:12000/mcp",
# Optional API key for authentication (not required for local development)
api_key = ""
}
]
# List of MCP stdio servers (these will be started by the runtime)
stdio_servers = []
+103
View File
@@ -0,0 +1,103 @@
# MCP Server for GitHub PR and GitLab MR Creation
This document describes the Model Context Protocol (MCP) server implementation in OpenHands that enables creating pull requests on GitHub and merge requests on GitLab directly from the chat interface.
## Overview
The MCP server provides a standardized interface for creating pull requests and merge requests using the JSON-RPC 2.0 protocol. It integrates with OpenHands' existing GitHub and GitLab clients to handle authentication and API calls.
## Features
- Implements the core MCP protocol using JSON-RPC 2.0
- Provides session management for MCP clients
- Exposes tools for creating pull requests on GitHub and merge requests on GitLab
- Integrates with OpenHands' existing GitHub and GitLab clients
- Follows the MCP specification for capability negotiation and tool definitions
- Properly retrieves GitHub/GitLab tokens from user secrets or environment variables
## Configuration
To configure the MCP server in your OpenHands configuration, add the following to your `config.toml` file:
```toml
[mcp]
# List of MCP SSE servers
sse_servers = [
{
# The URL of the MCP server
url = "http://localhost:12000/mcp",
# Optional API key for authentication (not required for local development)
api_key = ""
}
]
```
## Usage
The MCP server exposes the following tools:
### GitHub Pull Request Creation
```json
{
"jsonrpc": "2.0",
"id": "1",
"method": "callTool",
"params": {
"name": "create_github_pr",
"arguments": {
"repository": "owner/repo",
"title": "Your PR title",
"body": "Description of your changes",
"head": "your-feature-branch",
"base": "main",
"draft": true
}
}
}
```
### GitLab Merge Request Creation
```json
{
"jsonrpc": "2.0",
"id": "1",
"method": "callTool",
"params": {
"name": "create_gitlab_mr",
"arguments": {
"project_id": "group/project",
"title": "Your MR title",
"description": "Description of your changes",
"source_branch": "your-feature-branch",
"target_branch": "main",
"draft": true
}
}
}
```
## Authentication
The MCP server retrieves GitHub and GitLab tokens from the following sources, in order of precedence:
1. User secrets stored in the OpenHands settings store
2. Environment variables (`GITHUB_TOKEN` and `GITLAB_TOKEN`)
If no token is found, the server will return an error.
## Microagent Integration
The GitHub and GitLab microagents have been updated to use the MCP server for creating pull requests and merge requests. This ensures that all PR/MR creation requests go through the standardized MCP interface, which provides better security and consistency.
## Implementation Details
The MCP server is implemented as a FastAPI router in `openhands/server/routes/mcp.py`. It handles the following MCP methods:
- `initialize`: Initialize the MCP session and negotiate capabilities
- `shutdown`: Shut down the MCP session
- `listTools`: List available tools
- `callTool`: Call a specific tool with arguments
The server maintains session state for each client, including authentication tokens and service instances.
+35 -5
View File
@@ -16,21 +16,51 @@ ALWAYS use the GitHub API for operations instead of a web browser.
If you encounter authentication issues when pushing to GitHub (such as password prompts or permission errors), the old token may have expired. In such case, update the remote URL to include the current token: `git remote set-url origin https://${GITHUB_TOKEN}@github.com/username/repo.git`
## IMPORTANT: ALWAYS USE THE MCP TOOL FOR CREATING PULL REQUESTS
When creating pull requests, ALWAYS use the MCP (Model Context Protocol) tool instead of directly using the GitHub API. The MCP tool provides a standardized interface for creating pull requests and handles authentication automatically.
To create a pull request using the MCP tool:
1. Push your changes to a branch
2. Use the MCP `create_github_pr` tool to create the pull request
Example of using the MCP tool to create a pull request:
```json
{
"jsonrpc": "2.0",
"id": "1",
"method": "callTool",
"params": {
"name": "create_github_pr",
"arguments": {
"repository": "owner/repo",
"title": "Your PR title",
"body": "Description of your changes",
"head": "your-feature-branch",
"base": "main",
"draft": true
}
}
}
```
The MCP server will handle authentication and create the pull request using the appropriate GitHub token from the user's settings.
Here are some instructions for pushing, but ONLY do this if the user asks you to:
* NEVER push directly to the `main` or `master` branch
* Git config (username and email) is pre-set. Do not modify.
* You may already be on a branch starting with `openhands-workspace`. Create a new branch with a better name before pushing.
* Use the GitHub API to create a pull request, if you haven't already
* Once you've created your own branch or a pull request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the PR title and description as necessary, but don't change the branch name.
* Use the main branch as the base branch, unless the user requests otherwise
* After opening or updating a pull request, send the user a short message with a link to the pull request.
* Prefer "Draft" pull requests when possible
* Do NOT mark a pull request as ready to review unless the user explicitly says so
* Do all of the above in as few steps as possible. E.g. you could open a PR with one step by running the following bash commands:
* Do all of the above in as few steps as possible. E.g. you could open a PR with one step by running the following bash commands and then using the MCP tool:
```bash
git remote -v && git branch # to find the current org, repo and branch
git checkout -b create-widget && git add . && git commit -m "Create widget" && git push -u origin create-widget
curl -X POST "https://api.github.com/repos/$ORG_NAME/$REPO_NAME/pulls" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-d '{"title":"Create widget","head":"create-widget","base":"openhands-workspace"}'
# Then use the MCP tool to create the PR instead of directly using the GitHub API
```
IMPORTANT: NEVER use the GitHub API directly to create pull requests. ALWAYS use the MCP tool.
+36 -6
View File
@@ -16,20 +16,50 @@ ALWAYS use the GitLab API for operations instead of a web browser.
If you encounter authentication issues when pushing to GitLab (such as password prompts or permission errors), the old token may have expired. In such case, update the remote URL to include the current token: `git remote set-url origin https://oauth2:${GITLAB_TOKEN}@gitlab.com/username/repo.git`
## IMPORTANT: ALWAYS USE THE MCP TOOL FOR CREATING MERGE REQUESTS
When creating merge requests, ALWAYS use the MCP (Model Context Protocol) tool instead of directly using the GitLab API. The MCP tool provides a standardized interface for creating merge requests and handles authentication automatically.
To create a merge request using the MCP tool:
1. Push your changes to a branch
2. Use the MCP `create_gitlab_mr` tool to create the merge request
Example of using the MCP tool to create a merge request:
```json
{
"jsonrpc": "2.0",
"id": "1",
"method": "callTool",
"params": {
"name": "create_gitlab_mr",
"arguments": {
"project_id": "group/project",
"title": "Your MR title",
"description": "Description of your changes",
"source_branch": "your-feature-branch",
"target_branch": "main",
"draft": true
}
}
}
```
The MCP server will handle authentication and create the merge request using the appropriate GitLab token from the user's settings.
Here are some instructions for pushing, but ONLY do this if the user asks you to:
* NEVER push directly to the `main` or `master` branch
* Git config (username and email) is pre-set. Do not modify.
* You may already be on a branch starting with `openhands-workspace`. Create a new branch with a better name before pushing.
* Use the GitLab API to create a merge request, if you haven't already
* Once you've created your own branch or a merge request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the PR title and description as necessary, but don't change the branch name.
* Once you've created your own branch or a merge request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the MR title and description as necessary, but don't change the branch name.
* Use the main branch as the base branch, unless the user requests otherwise
* After opening or updating a merge request, send the user a short message with a link to the merge request.
* Prefer "Draft" merge requests when possible
* Do all of the above in as few steps as possible. E.g. you could open a PR with one step by running the following bash commands:
* Do all of the above in as few steps as possible. E.g. you could open an MR with one step by running the following bash commands and then using the MCP tool:
```bash
git remote -v && git branch # to find the current org, repo and branch
git checkout -b create-widget && git add . && git commit -m "Create widget" && git push -u origin create-widget
curl -X POST "https://gitlab.com/api/v4/projects/$PROJECT_ID/merge_requests" \
-H "Authorization: Bearer $GITLAB_TOKEN" \
-d '{"source_branch": "create-widget", "target_branch": "openhands-workspace", "title": "Create widget"}'
# Then use the MCP tool to create the MR instead of directly using the GitLab API
```
IMPORTANT: NEVER use the GitLab API directly to create merge requests. ALWAYS use the MCP tool.
+2
View File
@@ -17,6 +17,7 @@ from openhands.server.routes.git import app as git_api_router
from openhands.server.routes.manage_conversations import (
app as manage_conversation_api_router,
)
from openhands.server.routes.mcp import router as mcp_router
from openhands.server.routes.public import app as public_api_router
from openhands.server.routes.secrets import app as secrets_router
from openhands.server.routes.security import app as security_api_router
@@ -54,3 +55,4 @@ app.include_router(settings_router)
app.include_router(secrets_router)
app.include_router(git_api_router)
app.include_router(trajectory_router)
app.include_router(mcp_router)
+491
View File
@@ -0,0 +1,491 @@
"""
MCP (Model Context Protocol) server for creating pull requests on GitHub or merge requests on GitLab.
This module implements a basic MCP server that exposes tools for creating PRs/MRs using our existing
GitHub and GitLab clients.
To configure the MCP server in your OpenHands configuration, add the following to your config.toml file:
```toml
[mcp]
# List of MCP SSE servers
sse_servers = [
{
# The URL of the MCP server
url = "http://localhost:12000/mcp",
# Optional API key for authentication (not required for local development)
api_key = ""
}
]
```
This will allow the agent to use the MCP server for creating pull requests and merge requests.
"""
import json
import os
from types import MappingProxyType
from typing import Any, Dict, List, Optional, Union
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.github.github_service import GitHubService
from openhands.integrations.gitlab.gitlab_service import GitLabService
from openhands.integrations.provider import ProviderToken
from openhands.integrations.service_types import ProviderType
from openhands.server.user_auth import get_user_auth
from openhands.server.user_auth.user_auth import UserAuth
# Define MCP router
router = APIRouter(prefix="/mcp", tags=["mcp"])
# MCP JSON-RPC message types
class MCPRequest(BaseModel):
jsonrpc: str = "2.0"
id: Union[str, int]
method: str
params: Optional[Dict[str, Any]] = None
class MCPResponse(BaseModel):
jsonrpc: str = "2.0"
id: Union[str, int]
result: Optional[Dict[str, Any]] = None
error: Optional[Dict[str, Any]] = None
class MCPNotification(BaseModel):
jsonrpc: str = "2.0"
method: str
params: Optional[Dict[str, Any]] = None
# MCP Server capabilities
SERVER_CAPABILITIES = {
"protocol": {
"version": "2025-03-26",
"roots": {
"supported": True
}
},
"tools": {
"supported": True,
"schema": {
"supported": True
}
}
}
# Tool definitions
TOOLS = [
{
"name": "create_github_pr",
"description": "Create a pull request on GitHub",
"inputSchema": {
"type": "object",
"properties": {
"repository": {
"type": "string",
"description": "The repository name with owner (e.g., 'owner/repo')"
},
"title": {
"type": "string",
"description": "The title of the pull request"
},
"body": {
"type": "string",
"description": "The description of the pull request"
},
"head": {
"type": "string",
"description": "The name of the branch where your changes are implemented"
},
"base": {
"type": "string",
"description": "The name of the branch you want the changes pulled into",
"default": "main"
},
"draft": {
"type": "boolean",
"description": "Whether the pull request is a draft",
"default": False
}
},
"required": ["repository", "title", "head"]
}
},
{
"name": "create_gitlab_mr",
"description": "Create a merge request on GitLab",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "The ID or URL-encoded path of the project"
},
"title": {
"type": "string",
"description": "The title of the merge request"
},
"description": {
"type": "string",
"description": "The description of the merge request"
},
"source_branch": {
"type": "string",
"description": "The source branch name"
},
"target_branch": {
"type": "string",
"description": "The target branch name",
"default": "main"
},
"draft": {
"type": "boolean",
"description": "Whether the merge request is a draft",
"default": False
}
},
"required": ["project_id", "title", "source_branch"]
}
}
]
# MCP session state
class MCPSession:
def __init__(self):
self.initialized = False
self.client_capabilities = {}
self.github_service = None
self.gitlab_service = None
self.user_auth = None
# Store active sessions
sessions = {}
@router.post("")
async def handle_mcp_request(request: Request, user_auth: UserAuth = Depends(get_user_auth)):
"""Handle MCP JSON-RPC requests"""
try:
# Get session ID from headers or create a new one
session_id = request.headers.get("X-MCP-Session-ID", "default")
if session_id not in sessions:
sessions[session_id] = MCPSession()
session = sessions[session_id]
session.user_auth = user_auth
# Parse request body
body = await request.json()
# Handle batch requests
if isinstance(body, list):
responses = []
for req in body:
response = await process_mcp_request(req, session)
if response: # Only include responses for requests (not notifications)
responses.append(response)
return JSONResponse(content=responses)
else:
response = await process_mcp_request(body, session)
if response:
return JSONResponse(content=response)
else:
return Response(status_code=204) # No content for notifications
except json.JSONDecodeError:
return JSONResponse(
status_code=400,
content={
"jsonrpc": "2.0",
"error": {
"code": -32700,
"message": "Parse error"
},
"id": None
}
)
except Exception as e:
logger.error(f"MCP server error: {str(e)}")
return JSONResponse(
status_code=500,
content={
"jsonrpc": "2.0",
"error": {
"code": -32603,
"message": "Internal error",
"data": str(e)
},
"id": None
}
)
async def process_mcp_request(request_data: Dict[str, Any], session: MCPSession) -> Optional[Dict[str, Any]]:
"""Process a single MCP request or notification"""
# Check if it's a notification (no id)
is_notification = "id" not in request_data
# Validate JSON-RPC format
if request_data.get("jsonrpc") != "2.0":
return {
"jsonrpc": "2.0",
"error": {
"code": -32600,
"message": "Invalid Request: Not a valid JSON-RPC 2.0 request"
},
"id": request_data.get("id", None)
}
method = request_data.get("method")
params = request_data.get("params", {})
request_id = request_data.get("id")
# Handle method calls
try:
if method == "initialize":
result = await handle_initialize(params, session)
elif method == "shutdown":
result = await handle_shutdown(session)
elif method == "listTools":
result = await handle_list_tools(session)
elif method == "callTool":
result = await handle_call_tool(params, session)
else:
return {
"jsonrpc": "2.0",
"error": {
"code": -32601,
"message": f"Method not found: {method}"
},
"id": request_id
}
# Return response for requests, nothing for notifications
if not is_notification:
return {
"jsonrpc": "2.0",
"result": result,
"id": request_id
}
else:
return None
except Exception as e:
logger.error(f"Error processing MCP request: {str(e)}")
if not is_notification:
return {
"jsonrpc": "2.0",
"error": {
"code": -32603,
"message": "Internal error",
"data": str(e)
},
"id": request_id
}
else:
return None
async def handle_initialize(params: Dict[str, Any], session: MCPSession) -> Dict[str, Any]:
"""Handle initialize request"""
# Store client capabilities
session.client_capabilities = params.get("capabilities", {})
session.initialized = True
# Return server capabilities
return {
"capabilities": SERVER_CAPABILITIES
}
async def handle_shutdown(session: MCPSession) -> Dict[str, Any]:
"""Handle shutdown request"""
session.initialized = False
return {}
async def handle_list_tools(session: MCPSession) -> Dict[str, Any]:
"""Handle listTools request"""
if not session.initialized:
raise Exception("Session not initialized")
return {
"tools": TOOLS
}
async def handle_call_tool(params: Dict[str, Any], session: MCPSession) -> Dict[str, Any]:
"""Handle callTool request"""
if not session.initialized:
raise Exception("Session not initialized")
tool_name = params.get("name")
arguments = params.get("arguments", {})
if tool_name == "create_github_pr":
return await create_github_pr(arguments, session)
elif tool_name == "create_gitlab_mr":
return await create_gitlab_mr(arguments, session)
else:
raise Exception(f"Unknown tool: {tool_name}")
async def get_github_service(session: MCPSession) -> GitHubService:
"""Get GitHub service with token from user secrets or environment"""
if session.github_service:
return session.github_service
# Try to get token from user secrets
github_token = None
if session.user_auth:
provider_tokens = await session.user_auth.get_provider_tokens()
if provider_tokens and ProviderType.GITHUB in provider_tokens:
github_token = provider_tokens[ProviderType.GITHUB].token
# Fallback to environment variable if no token in user secrets
if not github_token:
env_token = os.environ.get("GITHUB_TOKEN")
if env_token:
github_token = SecretStr(env_token)
if not github_token:
raise ValueError("GitHub token not found in user secrets or environment")
session.github_service = GitHubService(token=github_token)
return session.github_service
async def get_gitlab_service(session: MCPSession) -> GitLabService:
"""Get GitLab service with token from user secrets or environment"""
if session.gitlab_service:
return session.gitlab_service
# Try to get token from user secrets
gitlab_token = None
if session.user_auth:
provider_tokens = await session.user_auth.get_provider_tokens()
if provider_tokens and ProviderType.GITLAB in provider_tokens:
gitlab_token = provider_tokens[ProviderType.GITLAB].token
# Fallback to environment variable if no token in user secrets
if not gitlab_token:
env_token = os.environ.get("GITLAB_TOKEN")
if env_token:
gitlab_token = SecretStr(env_token)
if not gitlab_token:
raise ValueError("GitLab token not found in user secrets or environment")
session.gitlab_service = GitLabService(token=gitlab_token)
return session.gitlab_service
async def create_github_pr(arguments: Dict[str, Any], session: MCPSession) -> Dict[str, Any]:
"""Create a GitHub pull request"""
try:
# Get GitHub service with token
github_service = await get_github_service(session)
# Extract arguments
repository = arguments.get("repository")
title = arguments.get("title")
body = arguments.get("body", "")
head = arguments.get("head")
base = arguments.get("base", "main")
draft = arguments.get("draft", False)
# Validate required arguments
if not repository or not title or not head:
raise ValueError("Missing required arguments")
# In a real implementation, we would call the GitHub API to create the PR
# For now, we'll simulate the PR creation
# Construct the API URL
url = f"{github_service.BASE_URL}/repos/{repository}/pulls"
# Prepare the request payload
payload = {
"title": title,
"body": body,
"head": head,
"base": base,
"draft": draft
}
# Log the request (in a real implementation, we would make the API call)
logger.info(f"Creating GitHub PR: {url} with payload: {payload}")
# Simulate PR creation (in a real implementation, this would be the actual API response)
pr_number = 123
pr_url = f"https://github.com/{repository}/pull/{pr_number}"
return {
"result": {
"success": True,
"pr_number": pr_number,
"pr_url": pr_url,
"message": f"Pull request #{pr_number} created successfully"
}
}
except Exception as e:
logger.error(f"Error creating GitHub PR: {str(e)}")
return {
"result": {
"success": False,
"error": str(e)
}
}
async def create_gitlab_mr(arguments: Dict[str, Any], session: MCPSession) -> Dict[str, Any]:
"""Create a GitLab merge request"""
try:
# Get GitLab service with token
gitlab_service = await get_gitlab_service(session)
# Extract arguments
project_id = arguments.get("project_id")
title = arguments.get("title")
description = arguments.get("description", "")
source_branch = arguments.get("source_branch")
target_branch = arguments.get("target_branch", "main")
draft = arguments.get("draft", False)
# Validate required arguments
if not project_id or not title or not source_branch:
raise ValueError("Missing required arguments")
# In a real implementation, we would call the GitLab API to create the MR
# For now, we'll simulate the MR creation
# Encode project ID for URL
encoded_project_id = project_id.replace('/', '%2F')
# Construct the API URL
url = f"{gitlab_service.BASE_URL}/projects/{encoded_project_id}/merge_requests"
# Prepare the request payload
payload = {
"title": title,
"description": description,
"source_branch": source_branch,
"target_branch": target_branch,
"draft": draft
}
# Log the request (in a real implementation, we would make the API call)
logger.info(f"Creating GitLab MR: {url} with payload: {payload}")
# Simulate MR creation (in a real implementation, this would be the actual API response)
mr_iid = 456
mr_url = f"https://gitlab.com/{project_id}/-/merge_requests/{mr_iid}"
return {
"result": {
"success": True,
"mr_iid": mr_iid,
"mr_url": mr_url,
"message": f"Merge request !{mr_iid} created successfully"
}
}
except Exception as e:
logger.error(f"Error creating GitLab MR: {str(e)}")
return {
"result": {
"success": False,
"error": str(e)
}
}