[Feat]: Git mcp server to open PRs (#8348)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: Robert Brennan <accounts@rbren.io>
This commit is contained in:
Rohit Malhotra 2025-05-21 11:48:02 -04:00 committed by GitHub
parent 7305c8fb31
commit 890796cc9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 2663 additions and 2158 deletions

View File

@ -14,13 +14,15 @@ the GitHub API.
You can use `curl` with the `GITHUB_TOKEN` to interact with GitHub's API.
ALWAYS use the GitHub API for operations instead of a web browser.
To open a pull request, always use the `create_pr` tool
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`
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
* Use the `create_pr` tool 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.
@ -30,7 +32,5 @@ Here are some instructions for pushing, but ONLY do this if the user asks you to
```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
```

View File

@ -14,13 +14,15 @@ the GitLab API.
You can use `curl` with the `GITLAB_TOKEN` to interact with GitLab's API.
ALWAYS use the GitLab API for operations instead of a web browser.
To open a merge request, always use the `create_mr` tool
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`
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
* Use the `create_mr` tool 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.
* 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.
@ -29,7 +31,5 @@ Here are some instructions for pushing, but ONLY do this if the user asks you to
```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
```

View File

@ -48,6 +48,7 @@ class AppConfig(BaseModel):
file_uploads_allowed_extensions: Allowed file extensions. `['.*']` allows all.
cli_multiline_input: Whether to enable multiline input in CLI. When disabled,
input is read line by line. When enabled, input continues until /exit command.
mcp_host: Host for OpenHands' default MCP server
mcp: MCP configuration settings.
"""
@ -92,6 +93,7 @@ class AppConfig(BaseModel):
max_concurrent_conversations: int = Field(
default=3
) # Maximum number of concurrent agent loops allowed per user
mcp_host: str = Field(default='localhost:3000')
mcp: MCPConfig = Field(default_factory=MCPConfig)
defaults_dict: ClassVar[dict] = {}
@ -141,5 +143,6 @@ class AppConfig(BaseModel):
def model_post_init(self, __context: Any) -> None:
"""Post-initialization hook, called when the instance is created with only default values."""
super().model_post_init(__context)
if not AppConfig.defaults_dict: # Only set defaults_dict if it's empty
AppConfig.defaults_dict = model_defaults_to_dict(self)

View File

@ -1,6 +1,8 @@
import os
from urllib.parse import urlparse
from pydantic import BaseModel, Field, ValidationError, model_validator
from openhands.utils.import_utils import get_impl
class MCPSSEServerConfig(BaseModel):
@ -120,3 +122,31 @@ class MCPConfig(BaseModel):
except ValidationError as e:
raise ValueError(f'Invalid MCP configuration: {e}')
return mcp_mapping
class OpenHandsMCPConfig:
@staticmethod
def create_default_mcp_server_config(host: str, user_id: str | None = None) -> MCPSSEServerConfig | None:
"""
Create a default MCP server configuration.
Args:
host: Host string
Returns:
MCPSSEServerConfig: A default SSE server configuration
"""
return MCPSSEServerConfig(url=f'http://{host}/mcp/sse', api_key=None)
openhands_mcp_config_cls = os.environ.get(
'OPENHANDS_MCP_CONFIG_CLS',
'openhands.core.config.mcp_config.OpenHandsMCPConfig',
)
OpenHandsMCPConfigImpl = get_impl(
OpenHandsMCPConfig, openhands_mcp_config_cls
)

View File

@ -448,6 +448,60 @@ class GitHubService(BaseGitService, GitService):
return all_branches
async def create_pr(
self,
repo_name: str,
source_branch: str,
target_branch: str,
title: str,
body: str | None = None,
draft: bool = True,
) -> str:
"""
Creates a PR using user credentials
Args:
repo_name: The full name of the repository (owner/repo)
source_branch: The name of the branch where your changes are implemented
target_branch: The name of the branch you want the changes pulled into
title: The title of the pull request (optional, defaults to a generic title)
body: The body/description of the pull request (optional)
draft: Whether to create the PR as a draft (optional, defaults to False)
Returns:
- PR URL when successful
- Error message when unsuccessful
"""
try:
url = f'{self.BASE_URL}/repos/{repo_name}/pulls'
# Set default body if none provided
if not body:
body = f'Merging changes from {source_branch} into {target_branch}'
# Prepare the request payload
payload = {
'title': title,
'head': source_branch,
'base': target_branch,
'body': body,
'draft': draft,
}
# Make the POST request to create the PR
response, _ = await self._make_request(
url=url, params=payload, method=RequestMethod.POST
)
# Return the HTML URL of the created PR
if 'html_url' in response:
return response['html_url']
else:
return f'PR created but URL not found in response: {response}'
except Exception as e:
return f'Error creating pull request: {str(e)}'
github_service_cls = os.environ.get(
'OPENHANDS_GITHUB_SERVICE_CLS',

View File

@ -438,6 +438,61 @@ class GitLabService(BaseGitService, GitService):
return all_branches
async def create_mr(
self,
id: int | str,
source_branch: str,
target_branch: str,
title: str,
description: str | None = None,
) -> str:
"""
Creates a merge request in GitLab
Args:
id: The ID or URL-encoded path of the project
source_branch: The name of the branch where your changes are implemented
target_branch: The name of the branch you want the changes merged into
title: The title of the merge request (optional, defaults to a generic title)
description: The description of the merge request (optional)
draft: Whether to create the MR as a draft (optional, defaults to False)
Returns:
- MR URL when successful
- Error message when unsuccessful
"""
try:
# Convert string ID to URL-encoded path if needed
project_id = str(id).replace('/', '%2F') if isinstance(id, str) else id
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests'
# Set default description if none provided
if not description:
description = f'Merging changes from {source_branch} into {target_branch}'
# Prepare the request payload
payload = {
'source_branch': source_branch,
'target_branch': target_branch,
'title': title,
'description': description,
}
# Make the POST request to create the MR
response, _ = await self._make_request(
url=url, params=payload, method=RequestMethod.POST
)
# Return the web URL of the created MR
if 'web_url' in response:
return response['web_url']
else:
return f'MR created but URL not found in response: {response}'
except Exception as e:
return f'Error creating merge request: {str(e)}'
gitlab_service_cls = os.environ.get(
'OPENHANDS_GITLAB_SERVICE_CLS',

View File

@ -25,7 +25,11 @@ class MCPClient(BaseModel):
arbitrary_types_allowed = True
async def connect_sse(
self, server_url: str, api_key: str | None = None, timeout: float = 30.0
self,
server_url: str,
api_key: str | None = None,
conversation_id: str | None = None,
timeout: float = 30.0,
) -> None:
"""Connect to an MCP server using SSE transport.
@ -41,9 +45,14 @@ class MCPClient(BaseModel):
try:
# Use asyncio.wait_for to enforce the timeout
async def connect_with_timeout():
headers = {'Authorization': f'Bearer {api_key}'} if api_key else {}
if conversation_id:
headers['X-OpenHands-Conversation-ID'] = conversation_id
streams_context = sse_client(
url=server_url,
headers={'Authorization': f'Bearer {api_key}'} if api_key else None,
headers=headers if headers else None,
timeout=timeout,
)
streams = await self.exit_stack.enter_async_context(streams_context)

View File

@ -44,7 +44,7 @@ def convert_mcp_clients_to_tools(mcp_clients: list[MCPClient] | None) -> list[di
async def create_mcp_clients(
sse_servers: list[MCPSSEServerConfig],
sse_servers: list[MCPSSEServerConfig], conversation_id: str | None = None
) -> list[MCPClient]:
mcp_clients: list[MCPClient] = []
# Initialize SSE connections
@ -56,7 +56,11 @@ async def create_mcp_clients(
client = MCPClient()
try:
await client.connect_sse(server_url.url, api_key=server_url.api_key)
await client.connect_sse(
server_url.url,
api_key=server_url.api_key,
conversation_id=conversation_id,
)
# Only add the client to the list after a successful connection
mcp_clients.append(client)
logger.info(f'Connected to MCP server {server_url} via SSE')
@ -155,6 +159,7 @@ async def add_mcp_tools_to_agent(
"""
Add MCP tools to an agent.
"""
from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient, # inline import to avoid circular import
)

View File

@ -356,6 +356,7 @@ class ActionExecutionClient(Runtime):
) -> MCPConfig:
# Add the runtime as another MCP server
updated_mcp_config = self.config.mcp.model_copy()
# Send a request to the action execution server to updated MCP config
stdio_tools = [
server.model_dump(mode='json')
@ -408,7 +409,7 @@ class ActionExecutionClient(Runtime):
)
# Create clients for this specific operation
mcp_clients = await create_mcp_clients(updated_mcp_config.sse_servers)
mcp_clients = await create_mcp_clients(updated_mcp_config.sse_servers, self.sid)
# Call the tool and return the result
# No need for try/finally since disconnect() is now just resetting state

View File

@ -2,6 +2,8 @@ import warnings
from contextlib import asynccontextmanager
from typing import AsyncIterator
from fastapi.routing import Mount
with warnings.catch_warnings():
warnings.simplefilter('ignore')
@ -18,6 +20,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 mcp_server
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
@ -37,6 +40,7 @@ app = FastAPI(
description='OpenHands: Code Less, Make More',
version=__version__,
lifespan=_lifespan,
routes=[Mount(path='/mcp', app=mcp_server.sse_app())],
)

View File

@ -0,0 +1,141 @@
import re
from typing import Annotated
from pydantic import Field
from fastmcp import FastMCP
from fastmcp.server.dependencies import get_http_request
from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.provider import ProviderToken
from openhands.integrations.service_types import ProviderType
from openhands.server.shared import ConversationStoreImpl, config
from openhands.server.user_auth import get_access_token, get_provider_tokens, get_user_id
from openhands.core.logger import openhands_logger as logger
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
mcp_server = FastMCP('mcp')
async def save_pr_metadata(user_id: str, conversation_id: str, tool_result: str) -> None:
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
conversation: ConversationMetadata = await conversation_store.get_metadata(conversation_id)
pull_pattern = r"pull/(\d+)"
merge_request_pattern = r"merge_requests/(\d+)"
# Check if the tool_result contains the PR number
pr_number = None
match_pull = re.search(pull_pattern, tool_result)
match_merge_request = re.search(merge_request_pattern, tool_result)
if match_pull:
pr_number = int(match_pull.group(1))
elif match_merge_request:
pr_number = int(match_merge_request.group(1))
if pr_number:
conversation.pr_number.append(pr_number)
await conversation_store.save_metadata(conversation)
@mcp_server.tool()
async def create_pr(
repo_name: Annotated[
str, Field(description='GitHub repository ({{owner}}/{{repo}})')
],
source_branch: Annotated[str, Field(description='Source branch on repo')],
target_branch: Annotated[str, Field(description='Target branch on repo')],
title: Annotated[str, Field(description='PR Title')],
body: Annotated[str | None, Field(description='PR body')]
) -> str:
"""Open a draft PR in GitHub"""
logger.info('Calling OpenHands MCP create_pr')
request = get_http_request()
headers = request.headers
conversation_id = headers.get('X-OpenHands-Conversation-ID', None)
provider_tokens = await get_provider_tokens(request)
access_token = await get_access_token(request)
user_id = await get_user_id(request)
github_token = provider_tokens.get(ProviderType.GITHUB, ProviderToken()) if provider_tokens else ProviderToken()
github_service = GithubServiceImpl(
user_id=github_token.user_id,
external_auth_id=user_id,
external_auth_token=access_token,
token=github_token.token,
base_domain=github_token.host
)
try:
response = await github_service.create_pr(
repo_name=repo_name,
source_branch=source_branch,
target_branch=target_branch,
title=title,
body=body
)
if conversation_id and user_id:
await save_pr_metadata(user_id, conversation_id, response)
except Exception as e:
response = str(e)
return response
@mcp_server.tool()
async def create_mr(
id: Annotated[
int | str, Field(description='GitLab repository (ID or URL-encoded path of the project)')
],
source_branch: Annotated[str, Field(description='Source branch on repo')],
target_branch: Annotated[str, Field(description='Target branch on repo')],
title: Annotated[str, Field(description='MR Title')],
description: Annotated[str | None, Field(description='MR description')]
) -> str:
"""Open a draft MR in GitLab"""
logger.info('Calling OpenHands MCP create_mr')
request = get_http_request()
headers = request.headers
conversation_id = headers.get('X-OpenHands-Conversation-ID', None)
provider_tokens = await get_provider_tokens(request)
access_token = await get_access_token(request)
user_id = await get_user_id(request)
github_token = provider_tokens.get(ProviderType.GITLAB, ProviderToken()) if provider_tokens else ProviderToken()
github_service = GitLabServiceImpl(
user_id=github_token.user_id,
external_auth_id=user_id,
external_auth_token=access_token,
token=github_token.token,
base_domain=github_token.host
)
try:
response = await github_service.create_mr(
id=id,
source_branch=source_branch,
target_branch=target_branch,
title=title,
description=description,
)
if conversation_id and user_id:
await save_pr_metadata(user_id, conversation_id, response)
except Exception as e:
response = str(e)
return response

View File

@ -17,6 +17,7 @@ from openhands.events.action import ChangeAgentStateAction, MessageAction
from openhands.events.event import Event, EventSource
from openhands.events.stream import EventStream
from openhands.integrations.provider import CUSTOM_SECRETS_TYPE, PROVIDER_TOKEN_TYPE, ProviderHandler
from openhands.integrations.service_types import ProviderType
from openhands.mcp import add_mcp_tools_to_agent
from openhands.memory.memory import Memory
from openhands.microagent.microagent import BaseMicroagent
@ -270,6 +271,23 @@ class AgentSession:
security_analyzer, SecurityAnalyzer
)(self.event_stream)
def override_provider_tokens_with_custom_secret(
self,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None,
custom_secrets: CUSTOM_SECRETS_TYPE | None
):
if git_provider_tokens and custom_secrets:
tokens = dict(git_provider_tokens)
for provider, _ in tokens.items():
token_name = ProviderHandler.get_provider_env_key(provider)
if token_name in custom_secrets or token_name.upper() in custom_secrets:
del tokens[provider]
return MappingProxyType(tokens)
return git_provider_tokens
async def _create_runtime(
self,
runtime_name: str,
@ -299,7 +317,11 @@ class AgentSession:
self.logger.debug(f'Initializing runtime `{runtime_name}` now...')
runtime_cls = get_runtime_cls(runtime_name)
if runtime_cls == RemoteRuntime:
if runtime_cls == RemoteRuntime:
# If provider tokens is passed in custom secrets, then remove provider from provider tokens
# We prioritize provider tokens set in custom secrets
provider_tokens_without_gitlab = self.override_provider_tokens_with_custom_secret(git_provider_tokens, custom_secrets)
self.runtime = runtime_cls(
config=config,
event_stream=self.event_stream,
@ -308,7 +330,7 @@ class AgentSession:
status_callback=self._status_callback,
headless_mode=False,
attach_to_existing=False,
git_provider_tokens=git_provider_tokens,
git_provider_tokens=provider_tokens_without_gitlab,
env_vars=env_vars,
user_id=self.user_id,
)

View File

@ -6,12 +6,13 @@ from logging import LoggerAdapter
import socketio
from openhands.controller.agent import Agent
from openhands.core.config import AppConfig, MCPConfig
from openhands.core.config import AppConfig
from openhands.core.config.condenser_config import (
BrowserOutputCondenserConfig,
CondenserPipelineConfig,
LLMSummarizingCondenserConfig,
)
from openhands.core.config.mcp_config import OpenHandsMCPConfigImpl
from openhands.core.exceptions import MicroagentValidationError
from openhands.core.logger import OpenHandsLoggerAdapter
from openhands.core.schema import AgentState
@ -115,7 +116,11 @@ class Session:
or settings.sandbox_runtime_container_image
else self.config.sandbox.runtime_container_image
)
self.config.mcp = settings.mcp_config or MCPConfig()
self.config.mcp = settings.mcp_config
# Add OpenHands' MCP server by default
openhands_mcp_server = OpenHandsMCPConfigImpl.create_default_mcp_server_config(self.config.mcp_host, self.user_id)
if openhands_mcp_server:
self.config.mcp.sse_servers.append(openhands_mcp_server)
max_iterations = settings.max_iterations or self.config.max_iterations
# This is a shallow copy of the default LLM config, so changes here will

View File

@ -20,6 +20,7 @@ class ConversationMetadata:
title: str | None = None
last_updated_at: datetime | None = None
trigger: ConversationTrigger | None = None
pr_number: list[int] = field(default_factory=list)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
# Cost and token metrics
accumulated_cost: float = 0.0

View File

@ -38,7 +38,8 @@ class Settings(BaseModel):
user_consents_to_analytics: bool | None = None
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
mcp_config: MCPConfig | None = None
mcp_config: MCPConfig = Field(default_factory=MCPConfig)
model_config = {
'validate_assignment': True,

4448
poetry.lock generated

File diff suppressed because one or more lines are too long

View File

@ -83,7 +83,7 @@ prompt-toolkit = "^3.0.50"
poetry = "^2.1.2"
anyio = "4.9.0"
pythonnet = "*"
mcp = "1.9.0"
fastmcp = "^2.3.3"
mcpm = "1.12.0"
[tool.poetry.group.dev.dependencies]

View File

@ -40,10 +40,10 @@ async def test_create_mcp_clients_success(mock_mcp_client):
# Check that connect_sse was called with correct parameters
mock_client_instance.connect_sse.assert_any_call(
'http://server1:8080', api_key=None
'http://server1:8080', api_key=None, conversation_id=None
)
mock_client_instance.connect_sse.assert_any_call(
'http://server2:8080', api_key='test-key'
'http://server2:8080', api_key='test-key', conversation_id=None
)