Skip to content

<feat>support sse and streamable-http mode #32

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
28 changes: 26 additions & 2 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The configuration file can be placed in either:
"base_url": "string"
},
"mcpServers": {
"server_name": {
"stdio_server_name": {
"command": "string",
"args": ["string"],
"env": {
Expand All @@ -30,6 +30,15 @@ The configuration file can be placed in either:
"enabled": boolean,
"exclude_tools": ["string"],
"requires_confirmation": ["string"]
},
"remote_server_name": {
"url": "string",
"headers": {},
"timeout": float,
"sse_read_timeout": float,
"enabled": boolean,
"exclude_tools": ["string"],
"requires_confirmation": ["string"]
}
}
}
Expand Down Expand Up @@ -62,9 +71,13 @@ The configuration file can be placed in either:

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `command` | string | Yes | - | Command to run the server |
| `command` | string | Yes (stdio mode) | - | Command to run the server |
| `args` | array | No | `[]` | Command-line arguments |
| `env` | object | No | `{}` | Environment variables |
| `url` | string | Yes (sse or streamable-http mode) | - | Sse or Streamable-HTTP url |
| `headers` | object | No | `{}` | Http request headers |
| `timeout` | number | No | 30 | Http request timeout |
| `sse_read_timeout` | number | No | 300 | Sse data read timeout |
| `enabled` | boolean | No | `true` | Whether the server is enabled |
| `exclude_tools` | array | No | `[]` | Tool names to exclude |
| `requires_confirmation` | array | No | `[]` | Tools requiring user confirmation |
Expand All @@ -85,6 +98,17 @@ The configuration file can be placed in either:
"command": "uvx",
"args": ["mcp-server-fetch"]
},
"add": {
"url": "http://localhost:8000/sse",
"headers": {},
"timeout": 50,
"requires_confirmation": ["add"]
},
"subtract": {
"url": "http://localhost:8000/mcp",
"headers": {},
"timeout": 50
},
"brave-search": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@ This act as alternative client beside Claude Desktop. Additionally you can use a
"base_url": "https://api.openai.com/v1" // Optional, for OpenRouter or other providers
},
"mcpServers": {
"add": {
"url": "http://localhost:8000/sse", // SSE
"headers": {},
"timeout": 50,
"requires_confirmation": ["add"],
"enabled": true, // Optional, defaults to true
"exclude_tools": [] // Optional, list of tool names to exclude
},
"subtract": {
"url": "http://localhost:8000/mcp", //Streamable-HTTP
"headers": {},
"timeout": 50,
"enabled": true, // Optional, defaults to true
"exclude_tools": [] // Optional, list of tool names to exclude
},
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"],
Expand Down
15 changes: 15 additions & 0 deletions mcp-server-config-example.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@
"command": "uvx",
"args": ["mcp-server-fetch"]
},
"add": {
"url": "http://localhost:8000/sse",
"headers": {},
"timeout": 50,
"requires_confirmation": ["add"],
"enabled": true,
"exclude_tools": []
},
"subtract": {
"url": "http://localhost:8000/mcp",
"headers": {},
"timeout": 50,
"enabled": true,
"exclude_tools": []
},
"brave-search": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
Expand Down
80 changes: 55 additions & 25 deletions src/mcp_client_cli/cli.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -111,18 +111,34 @@ def setup_argument_parser() -> argparse.Namespace:

async def handle_list_tools(app_config: AppConfig, args: argparse.Namespace) -> None:
"""Handle the --list-tools command."""
server_configs = [
McpServerConfig(
server_name=name,
server_param=StdioServerParameters(
server_configs = []
for name, config in app_config.get_enabled_servers().items():
mcp_type = McpType.STDIO
server_param = None
if config.command:
server_param = StdioServerParameters(
command=config.command,
args=config.args or [],
env={**(config.env or {}), **os.environ}
),
)
elif config.url and config.url.endswith('sse'):
mcp_type = McpType.SSE
elif config.url:
mcp_type = McpType.STREAMABLE_HTTP
if server_param is None:
server_param = StramableHttpOrSseParameters(
url = config.url,
headers=config.headers,
timeout=config.timeout,
sse_read_timeout=config.sse_read_timeout,
terminate_on_close=config.terminate_on_close,
)
server_configs.append(McpServerConfig(
mcp_type=mcp_type,
server_name=name,
server_param=server_param,
exclude_tools=config.exclude_tools or []
)
for name, config in app_config.get_enabled_servers().items()
]
))
toolkits, tools = await load_tools(server_configs, args.no_tools, args.force_refresh)

console = Console()
Expand Down Expand Up @@ -186,18 +202,34 @@ async def convert_toolkit(server_config: McpServerConfig):
async def handle_conversation(args: argparse.Namespace, query: HumanMessage,
is_conversation_continuation: bool, app_config: AppConfig) -> None:
"""Handle the main conversation flow."""
server_configs = [
McpServerConfig(
server_name=name,
server_param=StdioServerParameters(
server_configs = []
for name, config in app_config.get_enabled_servers().items():
mcp_type = McpType.STDIO
server_param = None
if config.command:
server_param = StdioServerParameters(
command=config.command,
args=config.args or [],
env={**(config.env or {}), **os.environ}
),
)
elif config.url and config.url.endswith('sse'):
mcp_type = McpType.SSE
elif config.url:
mcp_type = McpType.STREAMABLE_HTTP
if server_param is None:
server_param = StramableHttpOrSseParameters(
url = config.url,
headers=config.headers,
timeout=config.timeout,
sse_read_timeout=config.sse_read_timeout,
terminate_on_close=config.terminate_on_close
)
server_configs.append(McpServerConfig(
mcp_type=mcp_type,
server_name=name,
server_param=server_param,
exclude_tools=config.exclude_tools or []
)
for name, config in app_config.get_enabled_servers().items()
]
))
toolkits, tools = await load_tools(server_configs, args.no_tools, args.force_refresh)

extra_body = {}
Expand Down Expand Up @@ -233,19 +265,17 @@ async def handle_conversation(args: argparse.Namespace, query: HumanMessage,
formatted_memories = "\n".join(f"- {memory}" for memory in memories)
agent_executor = create_react_agent(
model, tools, state_schema=AgentState,
state_modifier=prompt, checkpointer=checkpointer, store=store
prompt=prompt, checkpointer=checkpointer, store=store
)

thread_id = (await conversation_manager.get_last_id() if is_conversation_continuation
else uuid.uuid4().hex)

input_messages = AgentState(
messages=[query],
today_datetime=datetime.now().isoformat(),
memories=formatted_memories,
remaining_steps=3
)

input_messages = {
"messages": [query],
"today_datetime": datetime.now().isoformat(),
"memories": formatted_memories,
"remaining_steps": 3
}
output = OutputHandler(text_only=args.text_only, only_last_message=args.no_intermediates)
output.start()
try:
Expand Down
17 changes: 13 additions & 4 deletions src/mcp_client_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import os
import commentjson
from typing import Dict, List, Optional

from .const import CONFIG_FILE, CONFIG_DIR

@dataclass
Expand All @@ -31,7 +30,13 @@ def from_dict(cls, config: dict) -> "LLMConfig":
@dataclass
class ServerConfig:
"""Configuration for an MCP server."""
command: str
url: str = None
headers: dict[str, str] | None = None
timeout: float = 30
sse_read_timeout: float = 60 * 5
terminate_on_close: bool = True

command: str = None
args: List[str] = None
env: Dict[str, str] = None
enabled: bool = True
Expand All @@ -42,7 +47,11 @@ class ServerConfig:
def from_dict(cls, config: dict) -> "ServerConfig":
"""Create ServerConfig from dictionary."""
return cls(
command=config["command"],
url=config.get("url", ""),
headers=config.get("headers", {}),
timeout=config.get("timeout", 30),
sse_read_timeout=config.get("sse_read_timeout", 60 * 5),
command=config.get("command", ""),
args=config.get("args", []),
env=config.get("env", {}),
enabled=config.get("enabled", True),
Expand All @@ -67,7 +76,7 @@ def load(cls) -> "AppConfig":
if chosen_path is None:
raise FileNotFoundError(f"Could not find config file in any of: {', '.join(map(str, config_paths))}")

with open(chosen_path, 'r') as f:
with open(chosen_path, 'r', encoding='utf-8') as f:
config = commentjson.load(f)

# Extract tools requiring confirmation
Expand Down
20 changes: 19 additions & 1 deletion src/mcp_client_cli/const.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
import httpx
from datetime import timedelta
from pathlib import Path
from enum import Enum
from pydantic import BaseModel

CACHE_EXPIRY_HOURS = 24
DEFAULT_QUERY = "Summarize https://www.youtube.com/watch?v=NExtKbS1Ljc"
CONFIG_FILE = 'mcp-server-config.json'
CONFIG_DIR = Path.home() / ".llm"
SQLITE_DB = CONFIG_DIR / "conversations.db"
CACHE_DIR = CONFIG_DIR / "mcp-tools"
CACHE_DIR = CONFIG_DIR / "mcp-tools"


class McpType(Enum):
STDIO = 1
SSE = 2
STREAMABLE_HTTP = 3


class StramableHttpOrSseParameters(BaseModel):
url: str
headers: dict[str, str] | None = None
timeout: float = 30
sse_read_timeout: float = 60 * 5
terminate_on_close: bool = True
21 changes: 14 additions & 7 deletions src/mcp_client_cli/storage.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
from datetime import datetime, timedelta
from typing import Optional, List
from typing import Optional, List, Union
from mcp import StdioServerParameters, types
import json
import aiosqlite
import uuid

from .const import *

def get_cached_tools(server_param: StdioServerParameters) -> Optional[List[types.Tool]]:
def get_cached_tools(server_param: Union[StdioServerParameters, StramableHttpOrSseParameters]) -> Optional[List[types.Tool]]:
"""Retrieve cached tools if available and not expired.

Args:
server_param (StdioServerParameters): The server parameters to identify the cache.
server_param (StdioServerParameters | StramableHttpOrSseParameters): The server parameters to identify the cache.

Returns:
Optional[List[types.Tool]]: A list of tools if cache is available and not expired, otherwise None.
"""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_key = f"{server_param.command}-{'-'.join(server_param.args)}".replace("/", "-")
if isinstance(server_param, StdioServerParameters):
cache_key = f"{server_param.command}-{'-'.join(server_param.args)}".replace("/", "-")
elif isinstance(server_param, StramableHttpOrSseParameters):
cache_key = f"{server_param.url}".replace("/", "-").replace(':', '')
cache_file = CACHE_DIR / f"{cache_key}.json"

if not cache_file.exists():
Expand All @@ -32,14 +35,18 @@ def get_cached_tools(server_param: StdioServerParameters) -> Optional[List[types
return [types.Tool(**tool) for tool in cache_data["tools"]]


def save_tools_cache(server_param: StdioServerParameters, tools: List[types.Tool]) -> None:
def save_tools_cache(server_param: Union[StdioServerParameters, StramableHttpOrSseParameters],
tools: List[types.Tool]) -> None:
"""Save tools to cache.

Args:
server_param (StdioServerParameters): The server parameters to identify the cache.
server_param (StdioServerParameters | StramableHttpOrSseParameters): The server parameters to identify the cache.
tools (List[types.Tool]): The list of tools to be cached.
"""
cache_key = f"{server_param.command}-{'-'.join(server_param.args)}".replace("/", "-")
if isinstance(server_param, StdioServerParameters):
cache_key = f"{server_param.command}-{'-'.join(server_param.args)}".replace("/", "-")
elif isinstance(server_param, StramableHttpOrSseParameters):
cache_key = f"{server_param.url}".replace("/", "-").replace(':', '')
cache_file = CACHE_DIR / f"{cache_key}.json"

cache_data = {
Expand Down
Loading