Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions commands/mcp.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from pydantic import BaseModel

from mcp_client import SSEServerConfig, StdioServerConfig
from mcp_client import HTTPServerConfig, SSEServerConfig, StdioServerConfig
from state import State


Expand All @@ -18,6 +18,7 @@ class MCPAddStdioContext(BaseModel):
class MCPAddHttpContext(BaseModel):
server_name: str
url: str
headers: list[str] | None = None


class MCPCommandContext(BaseModel):
Expand Down Expand Up @@ -52,7 +53,21 @@ async def handle_mcp(state: State, context: MCPCommandContext):
)

elif context.subcommand == "add-http" and context.add_http:
await handle_add_http(state, context.add_http.server_name, context.add_http.url)
headers_dict = {}
if context.add_http.headers:
for header in context.add_http.headers:
if "=" in header:
key, value = header.split("=", 1)
headers_dict[key] = value
else:
raise ValueError(f"Invalid header format: {header}. Use KEY=VALUE format.")

await handle_add_http(
state,
context.add_http.server_name,
context.add_http.url,
headers_dict if headers_dict else None,
)


async def handle_add_sse(state: State, server_name: str, url: str):
Expand Down Expand Up @@ -89,5 +104,14 @@ async def handle_add_stdio(
print(f"Environment variables: {env}")


async def handle_add_http(state: State, server_name: str, url: str):
print(f"HTTP server support not implemented yet. Would add '{server_name}' with URL: {url}")
async def handle_add_http(state: State, server_name: str, url: str, headers: dict[str, str] | None = None):
if server_name in state.mcp_config.mcpServers:
raise ValueError(f"Server '{server_name}' already exists")

server_config = HTTPServerConfig(url=url, headers=headers)
state.mcp_config.mcpServers[server_name] = server_config
state.save_mcp_config()

print(f"Added HTTP server '{server_name}' with URL: {url}")
if headers:
print(f"Headers: {headers}")
5 changes: 3 additions & 2 deletions cxk.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@ async def main():
add_stdio_parser.add_argument("--env", action="append", help="Environment variable (key=value)")
add_stdio_parser.add_argument("command_line", nargs=argparse.ONE_OR_MORE, help="Command to run")

# cxk mcp add-http [server-name] [url]
# cxk mcp add-http [server-name] [url] --header [header]
add_http_parser = mcp_subparsers.add_parser("add-http", help="Add HTTP MCP server")
add_http_parser.add_argument("server_name", help="Name of the server")
add_http_parser.add_argument("url", help="URL of the HTTP server")
add_http_parser.add_argument("--header", action="append", help="HTTP header (key=value)")

args = parser.parse_args()

Expand Down Expand Up @@ -91,7 +92,7 @@ async def main():
elif args.mcp_command == "add-http":
mcp_context = MCPCommandContext(
subcommand="add-http",
add_http=MCPAddHttpContext(server_name=args.server_name, url=args.url),
add_http=MCPAddHttpContext(server_name=args.server_name, url=args.url, headers=args.header),
)
await handle_mcp(state, mcp_context)

Expand Down
3 changes: 2 additions & 1 deletion mcp_client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""MCP (Model Context Protocol) client implementation."""

from .client_session_provider import get_client_session_by_server
from .config import MCPServersConfig, SSEServerConfig, StdioServerConfig
from .config import HTTPServerConfig, MCPServersConfig, SSEServerConfig, StdioServerConfig
from .session_manager import MCPSessionManager, get_session_manager
from .token_storage import KeychainTokenStorageWithFallback

__all__ = [
"HTTPServerConfig",
"MCPServersConfig",
"SSEServerConfig",
"StdioServerConfig",
Expand Down
40 changes: 24 additions & 16 deletions mcp_client/client_session_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from pydantic import AnyUrl

from auth_server import AuthServer
from mcp_client.config import HTTPServerConfig
from util.terminal import display_hyperlink

from .mcp_logger import get_mcp_log_file
Expand Down Expand Up @@ -45,23 +46,30 @@ async def get_stdio_session(server_params: StdioServerParameters, config_dir: Pa


@asynccontextmanager
async def get_streamablehttp_session(server_url: str, server_name: str, state: "State"):
token_storage = state.get_token_storage(server_name)
oauth_auth = OAuthClientProvider(
server_url=server_url,
client_metadata=OAuthClientMetadata(
client_name="ContextKit MCP Client",
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
scope="user",
),
storage=token_storage,
redirect_handler=handle_redirect,
callback_handler=handle_callback,
)
async def get_streamablehttp_session(
server_config: HTTPServerConfig, server_name: str, state: "State", auth_server: AuthServer | None = None
):
if auth_server is None:
raise ValueError("AuthServer must be provided for HTTP sessions")

# If the server_config.headers includes an Authorization header, skip oauth auth (PAT or API key used directly)
oauth_auth = None
if "Authorization" not in (server_config.headers or {}):
token_storage = state.get_token_storage(server_name)
oauth_auth = OAuthClientProvider(
server_url=server_config.url,
client_metadata=OAuthClientMetadata(
client_name="ContextKit MCP Client",
redirect_uris=[AnyUrl(auth_server.callback_url)],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
),
storage=token_storage,
redirect_handler=handle_redirect,
callback_handler=auth_server.handle_callback,
)
# Connect to a streamable HTTP server
async with streamablehttp_client(server_url, auth=oauth_auth) as (
async with streamablehttp_client(server_config.url, headers=server_config.headers, auth=oauth_auth) as (
read_stream,
write_stream,
_,
Expand Down
19 changes: 18 additions & 1 deletion mcp_client/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,25 @@ def validate_type(cls, v):
return v


class HTTPServerConfig(BaseServerConfig):
"""Configuration for HTTP-based MCP servers."""

type: str = Field(default="http", description="Server transport type")
url: str = Field(..., description="URL endpoint for the HTTP server")
headers: dict[str, str] | None = Field(default=None, description="HTTP headers")

@field_validator("type")
@classmethod
def validate_type(cls, v):
if v != "http":
raise ValueError('type must be "http" for HTTPServerConfig')
return v


class MCPServersConfig(BaseModel):
"""Root configuration containing all MCP servers."""

mcpServers: dict[str, StdioServerConfig | SSEServerConfig] = Field(
mcpServers: dict[str, StdioServerConfig | SSEServerConfig | HTTPServerConfig] = Field(
..., description="Dictionary of server name to server configuration"
)

Expand All @@ -74,6 +89,8 @@ def validate_servers(cls, values):

if server_type == "sse":
validated_servers[name] = SSEServerConfig(**config)
elif server_type == "http":
validated_servers[name] = HTTPServerConfig(**config)
else:
validated_servers[name] = StdioServerConfig(**config)
else:
Expand Down
6 changes: 5 additions & 1 deletion mcp_client/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from mcp import ClientSession

from .config import SSEServerConfig, StdioServerConfig
from .config import HTTPServerConfig, SSEServerConfig, StdioServerConfig

if TYPE_CHECKING:
from state import State
Expand Down Expand Up @@ -35,6 +35,7 @@ async def initialize_all_sessions(self, state: "State") -> None:

# Use shared auth server for all SSE connections during initialization
from auth_server import AuthServer

async with AuthServer() as auth_server:
for server_name, server_config in state.mcp_config.mcpServers.items():
try:
Expand All @@ -57,6 +58,9 @@ async def initialize_all_sessions(self, state: "State") -> None:
from .client_session_provider import get_sse_session

session_cm = get_sse_session(server_config.url, server_name, state, auth_server)
elif isinstance(server_config, HTTPServerConfig):
from .client_session_provider import get_streamablehttp_session
session_cm = get_streamablehttp_session(server_config, server_name, state, auth_server)
else:
raise ValueError(f"Unsupported server type for '{server_name}': {type(server_config)}")

Expand Down
56 changes: 50 additions & 6 deletions tests/e2e/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,24 +193,68 @@ def test_mcp_add_stdio_with_env(self, temp_git_repo):
assert server_config["args"] == ["server.js"]
assert server_config["env"] == {"API_KEY": "test123", "DEBUG": "true"}

def test_mcp_add_http_placeholder(self, temp_git_repo):
"""Test adding HTTP MCP server (placeholder functionality)."""
def test_mcp_add_http(self, temp_git_repo):
"""Test adding HTTP MCP server."""
# Initialize project first
init_result = self.run_cli(["init"], cwd=temp_git_repo)
assert init_result.returncode == 0

# Add HTTP server (should show placeholder message)
# Add HTTP server
result = self.run_cli(
["mcp", "add-http", "test-http", "http://example.com/api"],
cwd=temp_git_repo,
)

assert result.returncode == 0
assert (
"HTTP server support not implemented yet. Would add 'test-http' with URL: http://example.com/api"
in result.stdout
assert "Added HTTP server 'test-http' with URL: http://example.com/api" in result.stdout

# Verify the configuration was saved
config_file = temp_git_repo / ".cxk" / "mcp.json"
assert config_file.exists()

config_data = json.loads(config_file.read_text())
assert "test-http" in config_data["mcpServers"]
server_config = config_data["mcpServers"]["test-http"]
assert server_config["type"] == "http"
assert server_config["url"] == "http://example.com/api"

def test_mcp_add_http_with_headers(self, temp_git_repo):
"""Test adding HTTP MCP server with headers."""
# Initialize project first
init_result = self.run_cli(["init"], cwd=temp_git_repo)
assert init_result.returncode == 0

# Add HTTP server with headers
result = self.run_cli(
[
"mcp",
"add-http",
"test-http-headers",
"http://example.com/api",
"--header",
"Authorization=Bearer token123",
"--header",
"Content-Type=application/json",
],
cwd=temp_git_repo,
)

assert result.returncode == 0
assert "Added HTTP server 'test-http-headers' with URL: http://example.com/api" in result.stdout
assert "Headers: {'Authorization': 'Bearer token123', 'Content-Type': 'application/json'}" in result.stdout

# Verify the configuration was saved with headers
config_file = temp_git_repo / ".cxk" / "mcp.json"
assert config_file.exists()

config_data = json.loads(config_file.read_text())
assert "test-http-headers" in config_data["mcpServers"]
server_config = config_data["mcpServers"]["test-http-headers"]
assert server_config["type"] == "http"
assert server_config["url"] == "http://example.com/api"
assert server_config["headers"]["Authorization"] == "Bearer token123"
assert server_config["headers"]["Content-Type"] == "application/json"

def test_mcp_duplicate_server_name(self, temp_git_repo):
"""Test adding MCP server with duplicate name should fail."""
# Initialize project first
Expand Down
8 changes: 8 additions & 0 deletions tests/templates/spec9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Task Template

## Issue description

{% set issue = call_tool('github', 'get_issue', {'issue_number': 17, 'owner': 'eyalzh', 'repo': 'browser-control-mcp'}) %}

### Github Issue Description
{{ issue.body }}
Comment on lines +1 to +8
Copy link

Copilot AI Aug 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This template file appears unrelated to the HTTP support changes described in the PR title and should likely be in a separate commit or PR.

Suggested change
# Task Template
## Issue description
{% set issue = call_tool('github', 'get_issue', {'issue_number': 17, 'owner': 'eyalzh', 'repo': 'browser-control-mcp'}) %}
### Github Issue Description
{{ issue.body }}

Copilot uses AI. Check for mistakes.