Use 120 characters instead of 88 (#856)
This commit is contained in:
parent
f7265f7b91
commit
543961968c
90 changed files with 687 additions and 2142 deletions
|
|
@ -47,18 +47,14 @@ mcp = FastMCP(
|
||||||
|
|
||||||
DB_DSN = "postgresql://postgres:postgres@localhost:54320/memory_db"
|
DB_DSN = "postgresql://postgres:postgres@localhost:54320/memory_db"
|
||||||
# reset memory with rm ~/.fastmcp/{USER}/memory/*
|
# reset memory with rm ~/.fastmcp/{USER}/memory/*
|
||||||
PROFILE_DIR = (
|
PROFILE_DIR = (Path.home() / ".fastmcp" / os.environ.get("USER", "anon") / "memory").resolve()
|
||||||
Path.home() / ".fastmcp" / os.environ.get("USER", "anon") / "memory"
|
|
||||||
).resolve()
|
|
||||||
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def cosine_similarity(a: list[float], b: list[float]) -> float:
|
def cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||||
a_array = np.array(a, dtype=np.float64)
|
a_array = np.array(a, dtype=np.float64)
|
||||||
b_array = np.array(b, dtype=np.float64)
|
b_array = np.array(b, dtype=np.float64)
|
||||||
return np.dot(a_array, b_array) / (
|
return np.dot(a_array, b_array) / (np.linalg.norm(a_array) * np.linalg.norm(b_array))
|
||||||
np.linalg.norm(a_array) * np.linalg.norm(b_array)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def do_ai[T](
|
async def do_ai[T](
|
||||||
|
|
@ -97,9 +93,7 @@ class MemoryNode(BaseModel):
|
||||||
summary: str = ""
|
summary: str = ""
|
||||||
importance: float = 1.0
|
importance: float = 1.0
|
||||||
access_count: int = 0
|
access_count: int = 0
|
||||||
timestamp: float = Field(
|
timestamp: float = Field(default_factory=lambda: datetime.now(timezone.utc).timestamp())
|
||||||
default_factory=lambda: datetime.now(timezone.utc).timestamp()
|
|
||||||
)
|
|
||||||
embedding: list[float]
|
embedding: list[float]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -152,9 +146,7 @@ class MemoryNode(BaseModel):
|
||||||
self.importance += other.importance
|
self.importance += other.importance
|
||||||
self.access_count += other.access_count
|
self.access_count += other.access_count
|
||||||
self.embedding = [(a + b) / 2 for a, b in zip(self.embedding, other.embedding)]
|
self.embedding = [(a + b) / 2 for a, b in zip(self.embedding, other.embedding)]
|
||||||
self.summary = await do_ai(
|
self.summary = await do_ai(self.content, "Summarize the following text concisely.", str, deps)
|
||||||
self.content, "Summarize the following text concisely.", str, deps
|
|
||||||
)
|
|
||||||
await self.save(deps)
|
await self.save(deps)
|
||||||
# Delete the merged node from the database
|
# Delete the merged node from the database
|
||||||
if other.id is not None:
|
if other.id is not None:
|
||||||
|
|
@ -221,9 +213,7 @@ async def find_similar_memories(embedding: list[float], deps: Deps) -> list[Memo
|
||||||
|
|
||||||
async def update_importance(user_embedding: list[float], deps: Deps):
|
async def update_importance(user_embedding: list[float], deps: Deps):
|
||||||
async with deps.pool.acquire() as conn:
|
async with deps.pool.acquire() as conn:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch("SELECT id, importance, access_count, embedding FROM memories")
|
||||||
"SELECT id, importance, access_count, embedding FROM memories"
|
|
||||||
)
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
memory_embedding = row["embedding"]
|
memory_embedding = row["embedding"]
|
||||||
similarity = cosine_similarity(user_embedding, memory_embedding)
|
similarity = cosine_similarity(user_embedding, memory_embedding)
|
||||||
|
|
@ -273,9 +263,7 @@ async def display_memory_tree(deps: Deps) -> str:
|
||||||
)
|
)
|
||||||
result = ""
|
result = ""
|
||||||
for row in rows:
|
for row in rows:
|
||||||
effective_importance = row["importance"] * (
|
effective_importance = row["importance"] * (1 + math.log(row["access_count"] + 1))
|
||||||
1 + math.log(row["access_count"] + 1)
|
|
||||||
)
|
|
||||||
summary = row["summary"] or row["content"]
|
summary = row["summary"] or row["content"]
|
||||||
result += f"- {summary} (Importance: {effective_importance:.2f})\n"
|
result += f"- {summary} (Importance: {effective_importance:.2f})\n"
|
||||||
return result
|
return result
|
||||||
|
|
@ -283,15 +271,11 @@ async def display_memory_tree(deps: Deps) -> str:
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def remember(
|
async def remember(
|
||||||
contents: list[str] = Field(
|
contents: list[str] = Field(description="List of observations or memories to store"),
|
||||||
description="List of observations or memories to store"
|
|
||||||
),
|
|
||||||
):
|
):
|
||||||
deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool())
|
deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool())
|
||||||
try:
|
try:
|
||||||
return "\n".join(
|
return "\n".join(await asyncio.gather(*[add_memory(content, deps) for content in contents]))
|
||||||
await asyncio.gather(*[add_memory(content, deps) for content in contents])
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
await deps.pool.close()
|
await deps.pool.close()
|
||||||
|
|
||||||
|
|
@ -305,9 +289,7 @@ async def read_profile() -> str:
|
||||||
|
|
||||||
|
|
||||||
async def initialize_database():
|
async def initialize_database():
|
||||||
pool = await asyncpg.create_pool(
|
pool = await asyncpg.create_pool("postgresql://postgres:postgres@localhost:54320/postgres")
|
||||||
"postgresql://postgres:postgres@localhost:54320/postgres"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
|
|
|
||||||
|
|
@ -28,15 +28,11 @@ from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
|
||||||
class SurgeSettings(BaseSettings):
|
class SurgeSettings(BaseSettings):
|
||||||
model_config: SettingsConfigDict = SettingsConfigDict(
|
model_config: SettingsConfigDict = SettingsConfigDict(env_prefix="SURGE_", env_file=".env")
|
||||||
env_prefix="SURGE_", env_file=".env"
|
|
||||||
)
|
|
||||||
|
|
||||||
api_key: str
|
api_key: str
|
||||||
account_id: str
|
account_id: str
|
||||||
my_phone_number: Annotated[
|
my_phone_number: Annotated[str, BeforeValidator(lambda v: "+" + v if not v.startswith("+") else v)]
|
||||||
str, BeforeValidator(lambda v: "+" + v if not v.startswith("+") else v)
|
|
||||||
]
|
|
||||||
my_first_name: str
|
my_first_name: str
|
||||||
my_last_name: str
|
my_last_name: str
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,7 @@ from mcp.server.fastmcp import FastMCP
|
||||||
mcp = FastMCP()
|
mcp = FastMCP()
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(description="🌟 A tool that uses various Unicode characters in its description: " "á é í ó ú ñ 漢字 🎉")
|
||||||
description="🌟 A tool that uses various Unicode characters in its description: "
|
|
||||||
"á é í ó ú ñ 漢字 🎉"
|
|
||||||
)
|
|
||||||
def hello_unicode(name: str = "世界", greeting: str = "¡Hola") -> str:
|
def hello_unicode(name: str = "世界", greeting: str = "¡Hola") -> str:
|
||||||
"""
|
"""
|
||||||
A simple tool that demonstrates Unicode handling in:
|
A simple tool that demonstrates Unicode handling in:
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,7 @@ class SimpleGitHubOAuthProvider(OAuthAuthorizationServerProvider):
|
||||||
"""Register a new OAuth client."""
|
"""Register a new OAuth client."""
|
||||||
self.clients[client_info.client_id] = client_info
|
self.clients[client_info.client_id] = client_info
|
||||||
|
|
||||||
async def authorize(
|
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
||||||
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
|
||||||
) -> str:
|
|
||||||
"""Generate an authorization URL for GitHub OAuth flow."""
|
"""Generate an authorization URL for GitHub OAuth flow."""
|
||||||
state = params.state or secrets.token_hex(16)
|
state = params.state or secrets.token_hex(16)
|
||||||
|
|
||||||
|
|
@ -92,9 +90,7 @@ class SimpleGitHubOAuthProvider(OAuthAuthorizationServerProvider):
|
||||||
self.state_mapping[state] = {
|
self.state_mapping[state] = {
|
||||||
"redirect_uri": str(params.redirect_uri),
|
"redirect_uri": str(params.redirect_uri),
|
||||||
"code_challenge": params.code_challenge,
|
"code_challenge": params.code_challenge,
|
||||||
"redirect_uri_provided_explicitly": str(
|
"redirect_uri_provided_explicitly": str(params.redirect_uri_provided_explicitly),
|
||||||
params.redirect_uri_provided_explicitly
|
|
||||||
),
|
|
||||||
"client_id": client.client_id,
|
"client_id": client.client_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,9 +113,7 @@ class SimpleGitHubOAuthProvider(OAuthAuthorizationServerProvider):
|
||||||
|
|
||||||
redirect_uri = state_data["redirect_uri"]
|
redirect_uri = state_data["redirect_uri"]
|
||||||
code_challenge = state_data["code_challenge"]
|
code_challenge = state_data["code_challenge"]
|
||||||
redirect_uri_provided_explicitly = (
|
redirect_uri_provided_explicitly = state_data["redirect_uri_provided_explicitly"] == "True"
|
||||||
state_data["redirect_uri_provided_explicitly"] == "True"
|
|
||||||
)
|
|
||||||
client_id = state_data["client_id"]
|
client_id = state_data["client_id"]
|
||||||
|
|
||||||
# Exchange code for token with GitHub
|
# Exchange code for token with GitHub
|
||||||
|
|
@ -200,8 +194,7 @@ class SimpleGitHubOAuthProvider(OAuthAuthorizationServerProvider):
|
||||||
for token, data in self.tokens.items()
|
for token, data in self.tokens.items()
|
||||||
# see https://github.blog/engineering/platform-security/behind-githubs-new-authentication-token-formats/
|
# see https://github.blog/engineering/platform-security/behind-githubs-new-authentication-token-formats/
|
||||||
# which you get depends on your GH app setup.
|
# which you get depends on your GH app setup.
|
||||||
if (token.startswith("ghu_") or token.startswith("gho_"))
|
if (token.startswith("ghu_") or token.startswith("gho_")) and data.client_id == client.client_id
|
||||||
and data.client_id == client.client_id
|
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
@ -232,9 +225,7 @@ class SimpleGitHubOAuthProvider(OAuthAuthorizationServerProvider):
|
||||||
|
|
||||||
return access_token
|
return access_token
|
||||||
|
|
||||||
async def load_refresh_token(
|
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
|
||||||
self, client: OAuthClientInformationFull, refresh_token: str
|
|
||||||
) -> RefreshToken | None:
|
|
||||||
"""Load a refresh token - not supported."""
|
"""Load a refresh token - not supported."""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -247,9 +238,7 @@ class SimpleGitHubOAuthProvider(OAuthAuthorizationServerProvider):
|
||||||
"""Exchange refresh token"""
|
"""Exchange refresh token"""
|
||||||
raise NotImplementedError("Not supported")
|
raise NotImplementedError("Not supported")
|
||||||
|
|
||||||
async def revoke_token(
|
async def revoke_token(self, token: str, token_type_hint: str | None = None) -> None:
|
||||||
self, token: str, token_type_hint: str | None = None
|
|
||||||
) -> None:
|
|
||||||
"""Revoke a token."""
|
"""Revoke a token."""
|
||||||
if token in self.tokens:
|
if token in self.tokens:
|
||||||
del self.tokens[token]
|
del self.tokens[token]
|
||||||
|
|
@ -335,9 +324,7 @@ def create_simple_mcp_server(settings: ServerSettings) -> FastMCP:
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise ValueError(
|
raise ValueError(f"GitHub API error: {response.status_code} - {response.text}")
|
||||||
f"GitHub API error: {response.status_code} - {response.text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
@ -361,9 +348,7 @@ def main(port: int, host: str, transport: Literal["sse", "streamable-http"]) ->
|
||||||
# No hardcoded credentials - all from environment variables
|
# No hardcoded credentials - all from environment variables
|
||||||
settings = ServerSettings(host=host, port=port)
|
settings = ServerSettings(host=host, port=port)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.error(
|
logger.error("Failed to load settings. Make sure environment variables are set:")
|
||||||
"Failed to load settings. Make sure environment variables are set:"
|
|
||||||
)
|
|
||||||
logger.error(" MCP_GITHUB_GITHUB_CLIENT_ID=<your-client-id>")
|
logger.error(" MCP_GITHUB_GITHUB_CLIENT_ID=<your-client-id>")
|
||||||
logger.error(" MCP_GITHUB_GITHUB_CLIENT_SECRET=<your-client-secret>")
|
logger.error(" MCP_GITHUB_GITHUB_CLIENT_SECRET=<your-client-secret>")
|
||||||
logger.error(f"Error: {e}")
|
logger.error(f"Error: {e}")
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ select = ["C4", "E", "F", "I", "PERF", "UP"]
|
||||||
ignore = ["PERF203"]
|
ignore = ["PERF203"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 88
|
line-length = 120
|
||||||
target-version = "py310"
|
target-version = "py310"
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,7 @@ def get_claude_config_path() -> Path | None:
|
||||||
elif sys.platform == "darwin":
|
elif sys.platform == "darwin":
|
||||||
path = Path(Path.home(), "Library", "Application Support", "Claude")
|
path = Path(Path.home(), "Library", "Application Support", "Claude")
|
||||||
elif sys.platform.startswith("linux"):
|
elif sys.platform.startswith("linux"):
|
||||||
path = Path(
|
path = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), "Claude")
|
||||||
os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), "Claude"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -37,8 +35,7 @@ def get_uv_path() -> str:
|
||||||
uv_path = shutil.which("uv")
|
uv_path = shutil.which("uv")
|
||||||
if not uv_path:
|
if not uv_path:
|
||||||
logger.error(
|
logger.error(
|
||||||
"uv executable not found in PATH, falling back to 'uv'. "
|
"uv executable not found in PATH, falling back to 'uv'. " "Please ensure uv is installed and in your PATH"
|
||||||
"Please ensure uv is installed and in your PATH"
|
|
||||||
)
|
)
|
||||||
return "uv" # Fall back to just "uv" if not found
|
return "uv" # Fall back to just "uv" if not found
|
||||||
return uv_path
|
return uv_path
|
||||||
|
|
@ -94,10 +91,7 @@ def update_claude_config(
|
||||||
config["mcpServers"] = {}
|
config["mcpServers"] = {}
|
||||||
|
|
||||||
# Always preserve existing env vars and merge with new ones
|
# Always preserve existing env vars and merge with new ones
|
||||||
if (
|
if server_name in config["mcpServers"] and "env" in config["mcpServers"][server_name]:
|
||||||
server_name in config["mcpServers"]
|
|
||||||
and "env" in config["mcpServers"][server_name]
|
|
||||||
):
|
|
||||||
existing_env = config["mcpServers"][server_name]["env"]
|
existing_env = config["mcpServers"][server_name]["env"]
|
||||||
if env_vars:
|
if env_vars:
|
||||||
# New vars take precedence over existing ones
|
# New vars take precedence over existing ones
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,7 @@ def _get_npx_command():
|
||||||
# Try both npx.cmd and npx.exe on Windows
|
# Try both npx.cmd and npx.exe on Windows
|
||||||
for cmd in ["npx.cmd", "npx.exe", "npx"]:
|
for cmd in ["npx.cmd", "npx.exe", "npx"]:
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run([cmd, "--version"], check=True, capture_output=True, shell=True)
|
||||||
[cmd, "--version"], check=True, capture_output=True, shell=True
|
|
||||||
)
|
|
||||||
return cmd
|
return cmd
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
continue
|
continue
|
||||||
|
|
@ -58,9 +56,7 @@ def _get_npx_command():
|
||||||
def _parse_env_var(env_var: str) -> tuple[str, str]:
|
def _parse_env_var(env_var: str) -> tuple[str, str]:
|
||||||
"""Parse environment variable string in format KEY=VALUE."""
|
"""Parse environment variable string in format KEY=VALUE."""
|
||||||
if "=" not in env_var:
|
if "=" not in env_var:
|
||||||
logger.error(
|
logger.error(f"Invalid environment variable format: {env_var}. Must be KEY=VALUE")
|
||||||
f"Invalid environment variable format: {env_var}. Must be KEY=VALUE"
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
key, value = env_var.split("=", 1)
|
key, value = env_var.split("=", 1)
|
||||||
return key.strip(), value.strip()
|
return key.strip(), value.strip()
|
||||||
|
|
@ -154,14 +150,10 @@ def _import_server(file: Path, server_object: str | None = None):
|
||||||
True if it's supported.
|
True if it's supported.
|
||||||
"""
|
"""
|
||||||
if not isinstance(server_object, FastMCP):
|
if not isinstance(server_object, FastMCP):
|
||||||
logger.error(
|
logger.error(f"The server object {object_name} is of type " f"{type(server_object)} (expecting {FastMCP}).")
|
||||||
f"The server object {object_name} is of type "
|
|
||||||
f"{type(server_object)} (expecting {FastMCP})."
|
|
||||||
)
|
|
||||||
if isinstance(server_object, LowLevelServer):
|
if isinstance(server_object, LowLevelServer):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Note that only FastMCP server is supported. Low level "
|
"Note that only FastMCP server is supported. Low level " "Server class is not yet supported."
|
||||||
"Server class is not yet supported."
|
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
@ -172,10 +164,7 @@ def _import_server(file: Path, server_object: str | None = None):
|
||||||
for name in ["mcp", "server", "app"]:
|
for name in ["mcp", "server", "app"]:
|
||||||
if hasattr(module, name):
|
if hasattr(module, name):
|
||||||
if not _check_server_object(getattr(module, name), f"{file}:{name}"):
|
if not _check_server_object(getattr(module, name), f"{file}:{name}"):
|
||||||
logger.error(
|
logger.error(f"Ignoring object '{file}:{name}' as it's not a valid " "server object")
|
||||||
f"Ignoring object '{file}:{name}' as it's not a valid "
|
|
||||||
"server object"
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
return getattr(module, name)
|
return getattr(module, name)
|
||||||
|
|
||||||
|
|
@ -280,8 +269,7 @@ def dev(
|
||||||
npx_cmd = _get_npx_command()
|
npx_cmd = _get_npx_command()
|
||||||
if not npx_cmd:
|
if not npx_cmd:
|
||||||
logger.error(
|
logger.error(
|
||||||
"npx not found. Please ensure Node.js and npm are properly installed "
|
"npx not found. Please ensure Node.js and npm are properly installed " "and added to your system PATH."
|
||||||
"and added to your system PATH."
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
@ -383,8 +371,7 @@ def install(
|
||||||
typer.Option(
|
typer.Option(
|
||||||
"--name",
|
"--name",
|
||||||
"-n",
|
"-n",
|
||||||
help="Custom name for the server (defaults to server's name attribute or"
|
help="Custom name for the server (defaults to server's name attribute or" " file name)",
|
||||||
" file name)",
|
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
with_editable: Annotated[
|
with_editable: Annotated[
|
||||||
|
|
@ -458,8 +445,7 @@ def install(
|
||||||
name = server.name
|
name = server.name
|
||||||
except (ImportError, ModuleNotFoundError) as e:
|
except (ImportError, ModuleNotFoundError) as e:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Could not import server (likely missing dependencies), using file"
|
"Could not import server (likely missing dependencies), using file" " name",
|
||||||
" name",
|
|
||||||
extra={"error": str(e)},
|
extra={"error": str(e)},
|
||||||
)
|
)
|
||||||
name = file.stem
|
name = file.stem
|
||||||
|
|
@ -477,11 +463,7 @@ def install(
|
||||||
if env_file:
|
if env_file:
|
||||||
if dotenv:
|
if dotenv:
|
||||||
try:
|
try:
|
||||||
env_dict |= {
|
env_dict |= {k: v for k, v in dotenv.dotenv_values(env_file).items() if v is not None}
|
||||||
k: v
|
|
||||||
for k, v in dotenv.dotenv_values(env_file).items()
|
|
||||||
if v is not None
|
|
||||||
}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load .env file: {e}")
|
logger.error(f"Failed to load .env file: {e}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,7 @@ logger = logging.getLogger("client")
|
||||||
|
|
||||||
|
|
||||||
async def message_handler(
|
async def message_handler(
|
||||||
message: RequestResponder[types.ServerRequest, types.ClientResult]
|
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
|
||||||
| types.ServerNotification
|
|
||||||
| Exception,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if isinstance(message, Exception):
|
if isinstance(message, Exception):
|
||||||
logger.error("Error: %s", message)
|
logger.error("Error: %s", message)
|
||||||
|
|
@ -60,9 +58,7 @@ async def main(command_or_url: str, args: list[str], env: list[tuple[str, str]])
|
||||||
await run_session(*streams)
|
await run_session(*streams)
|
||||||
else:
|
else:
|
||||||
# Use stdio client for commands
|
# Use stdio client for commands
|
||||||
server_parameters = StdioServerParameters(
|
server_parameters = StdioServerParameters(command=command_or_url, args=args, env=env_dict)
|
||||||
command=command_or_url, args=args, env=env_dict
|
|
||||||
)
|
|
||||||
async with stdio_client(server_parameters) as streams:
|
async with stdio_client(server_parameters) as streams:
|
||||||
await run_session(*streams)
|
await run_session(*streams)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,7 @@ from urllib.parse import urlencode, urljoin
|
||||||
import anyio
|
import anyio
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from mcp.shared.auth import (
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata, OAuthToken
|
||||||
OAuthClientInformationFull,
|
|
||||||
OAuthClientMetadata,
|
|
||||||
OAuthMetadata,
|
|
||||||
OAuthToken,
|
|
||||||
)
|
|
||||||
from mcp.types import LATEST_PROTOCOL_VERSION
|
from mcp.types import LATEST_PROTOCOL_VERSION
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -100,10 +95,7 @@ class OAuthClientProvider(httpx.Auth):
|
||||||
|
|
||||||
def _generate_code_verifier(self) -> str:
|
def _generate_code_verifier(self) -> str:
|
||||||
"""Generate a cryptographically random code verifier for PKCE."""
|
"""Generate a cryptographically random code verifier for PKCE."""
|
||||||
return "".join(
|
return "".join(secrets.choice(string.ascii_letters + string.digits + "-._~") for _ in range(128))
|
||||||
secrets.choice(string.ascii_letters + string.digits + "-._~")
|
|
||||||
for _ in range(128)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _generate_code_challenge(self, code_verifier: str) -> str:
|
def _generate_code_challenge(self, code_verifier: str) -> str:
|
||||||
"""Generate a code challenge from a code verifier using SHA256."""
|
"""Generate a code challenge from a code verifier using SHA256."""
|
||||||
|
|
@ -148,9 +140,7 @@ class OAuthClientProvider(httpx.Auth):
|
||||||
return None
|
return None
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
metadata_json = response.json()
|
metadata_json = response.json()
|
||||||
logger.debug(
|
logger.debug(f"OAuth metadata discovered (no MCP header): {metadata_json}")
|
||||||
f"OAuth metadata discovered (no MCP header): {metadata_json}"
|
|
||||||
)
|
|
||||||
return OAuthMetadata.model_validate(metadata_json)
|
return OAuthMetadata.model_validate(metadata_json)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to discover OAuth metadata")
|
logger.exception("Failed to discover OAuth metadata")
|
||||||
|
|
@ -176,17 +166,11 @@ class OAuthClientProvider(httpx.Auth):
|
||||||
registration_url = urljoin(auth_base_url, "/register")
|
registration_url = urljoin(auth_base_url, "/register")
|
||||||
|
|
||||||
# Handle default scope
|
# Handle default scope
|
||||||
if (
|
if client_metadata.scope is None and metadata and metadata.scopes_supported is not None:
|
||||||
client_metadata.scope is None
|
|
||||||
and metadata
|
|
||||||
and metadata.scopes_supported is not None
|
|
||||||
):
|
|
||||||
client_metadata.scope = " ".join(metadata.scopes_supported)
|
client_metadata.scope = " ".join(metadata.scopes_supported)
|
||||||
|
|
||||||
# Serialize client metadata
|
# Serialize client metadata
|
||||||
registration_data = client_metadata.model_dump(
|
registration_data = client_metadata.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||||
by_alias=True, mode="json", exclude_none=True
|
|
||||||
)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
|
|
@ -213,9 +197,7 @@ class OAuthClientProvider(httpx.Auth):
|
||||||
logger.exception("Registration error")
|
logger.exception("Registration error")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def async_auth_flow(
|
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
||||||
self, request: httpx.Request
|
|
||||||
) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
|
||||||
"""
|
"""
|
||||||
HTTPX auth flow integration.
|
HTTPX auth flow integration.
|
||||||
"""
|
"""
|
||||||
|
|
@ -225,9 +207,7 @@ class OAuthClientProvider(httpx.Auth):
|
||||||
await self.ensure_token()
|
await self.ensure_token()
|
||||||
# Add Bearer token if available
|
# Add Bearer token if available
|
||||||
if self._current_tokens and self._current_tokens.access_token:
|
if self._current_tokens and self._current_tokens.access_token:
|
||||||
request.headers["Authorization"] = (
|
request.headers["Authorization"] = f"Bearer {self._current_tokens.access_token}"
|
||||||
f"Bearer {self._current_tokens.access_token}"
|
|
||||||
)
|
|
||||||
|
|
||||||
response = yield request
|
response = yield request
|
||||||
|
|
||||||
|
|
@ -305,11 +285,7 @@ class OAuthClientProvider(httpx.Auth):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Try refreshing existing token
|
# Try refreshing existing token
|
||||||
if (
|
if self._current_tokens and self._current_tokens.refresh_token and await self._refresh_access_token():
|
||||||
self._current_tokens
|
|
||||||
and self._current_tokens.refresh_token
|
|
||||||
and await self._refresh_access_token()
|
|
||||||
):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Fall back to full OAuth flow
|
# Fall back to full OAuth flow
|
||||||
|
|
@ -361,12 +337,8 @@ class OAuthClientProvider(httpx.Auth):
|
||||||
auth_code, returned_state = await self.callback_handler()
|
auth_code, returned_state = await self.callback_handler()
|
||||||
|
|
||||||
# Validate state parameter for CSRF protection
|
# Validate state parameter for CSRF protection
|
||||||
if returned_state is None or not secrets.compare_digest(
|
if returned_state is None or not secrets.compare_digest(returned_state, self._auth_state):
|
||||||
returned_state, self._auth_state
|
raise Exception(f"State parameter mismatch: {returned_state} != {self._auth_state}")
|
||||||
):
|
|
||||||
raise Exception(
|
|
||||||
f"State parameter mismatch: {returned_state} != {self._auth_state}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Clear state after validation
|
# Clear state after validation
|
||||||
self._auth_state = None
|
self._auth_state = None
|
||||||
|
|
@ -377,9 +349,7 @@ class OAuthClientProvider(httpx.Auth):
|
||||||
# Exchange authorization code for tokens
|
# Exchange authorization code for tokens
|
||||||
await self._exchange_code_for_token(auth_code, client_info)
|
await self._exchange_code_for_token(auth_code, client_info)
|
||||||
|
|
||||||
async def _exchange_code_for_token(
|
async def _exchange_code_for_token(self, auth_code: str, client_info: OAuthClientInformationFull) -> None:
|
||||||
self, auth_code: str, client_info: OAuthClientInformationFull
|
|
||||||
) -> None:
|
|
||||||
"""Exchange authorization code for access token."""
|
"""Exchange authorization code for access token."""
|
||||||
# Get token endpoint
|
# Get token endpoint
|
||||||
if self._metadata and self._metadata.token_endpoint:
|
if self._metadata and self._metadata.token_endpoint:
|
||||||
|
|
@ -412,17 +382,10 @@ class OAuthClientProvider(httpx.Auth):
|
||||||
# Parse OAuth error response
|
# Parse OAuth error response
|
||||||
try:
|
try:
|
||||||
error_data = response.json()
|
error_data = response.json()
|
||||||
error_msg = error_data.get(
|
error_msg = error_data.get("error_description", error_data.get("error", "Unknown error"))
|
||||||
"error_description", error_data.get("error", "Unknown error")
|
raise Exception(f"Token exchange failed: {error_msg} " f"(HTTP {response.status_code})")
|
||||||
)
|
|
||||||
raise Exception(
|
|
||||||
f"Token exchange failed: {error_msg} "
|
|
||||||
f"(HTTP {response.status_code})"
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
raise Exception(
|
raise Exception(f"Token exchange failed: {response.status_code} {response.text}")
|
||||||
f"Token exchange failed: {response.status_code} {response.text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse token response
|
# Parse token response
|
||||||
token_response = OAuthToken.model_validate(response.json())
|
token_response = OAuthToken.model_validate(response.json())
|
||||||
|
|
|
||||||
|
|
@ -38,16 +38,12 @@ class LoggingFnT(Protocol):
|
||||||
class MessageHandlerFnT(Protocol):
|
class MessageHandlerFnT(Protocol):
|
||||||
async def __call__(
|
async def __call__(
|
||||||
self,
|
self,
|
||||||
message: RequestResponder[types.ServerRequest, types.ClientResult]
|
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
|
||||||
| types.ServerNotification
|
|
||||||
| Exception,
|
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
async def _default_message_handler(
|
async def _default_message_handler(
|
||||||
message: RequestResponder[types.ServerRequest, types.ClientResult]
|
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
|
||||||
| types.ServerNotification
|
|
||||||
| Exception,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
await anyio.lowlevel.checkpoint()
|
await anyio.lowlevel.checkpoint()
|
||||||
|
|
||||||
|
|
@ -77,9 +73,7 @@ async def _default_logging_callback(
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(
|
ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData)
|
||||||
types.ClientResult | types.ErrorData
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClientSession(
|
class ClientSession(
|
||||||
|
|
@ -116,11 +110,7 @@ class ClientSession(
|
||||||
self._message_handler = message_handler or _default_message_handler
|
self._message_handler = message_handler or _default_message_handler
|
||||||
|
|
||||||
async def initialize(self) -> types.InitializeResult:
|
async def initialize(self) -> types.InitializeResult:
|
||||||
sampling = (
|
sampling = types.SamplingCapability() if self._sampling_callback is not _default_sampling_callback else None
|
||||||
types.SamplingCapability()
|
|
||||||
if self._sampling_callback is not _default_sampling_callback
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
roots = (
|
roots = (
|
||||||
# TODO: Should this be based on whether we
|
# TODO: Should this be based on whether we
|
||||||
# _will_ send notifications, or only whether
|
# _will_ send notifications, or only whether
|
||||||
|
|
@ -149,15 +139,10 @@ class ClientSession(
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.protocolVersion not in SUPPORTED_PROTOCOL_VERSIONS:
|
if result.protocolVersion not in SUPPORTED_PROTOCOL_VERSIONS:
|
||||||
raise RuntimeError(
|
raise RuntimeError("Unsupported protocol version from the server: " f"{result.protocolVersion}")
|
||||||
"Unsupported protocol version from the server: "
|
|
||||||
f"{result.protocolVersion}"
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.send_notification(
|
await self.send_notification(
|
||||||
types.ClientNotification(
|
types.ClientNotification(types.InitializedNotification(method="notifications/initialized"))
|
||||||
types.InitializedNotification(method="notifications/initialized")
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
@ -207,33 +192,25 @@ class ClientSession(
|
||||||
types.EmptyResult,
|
types.EmptyResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def list_resources(
|
async def list_resources(self, cursor: str | None = None) -> types.ListResourcesResult:
|
||||||
self, cursor: str | None = None
|
|
||||||
) -> types.ListResourcesResult:
|
|
||||||
"""Send a resources/list request."""
|
"""Send a resources/list request."""
|
||||||
return await self.send_request(
|
return await self.send_request(
|
||||||
types.ClientRequest(
|
types.ClientRequest(
|
||||||
types.ListResourcesRequest(
|
types.ListResourcesRequest(
|
||||||
method="resources/list",
|
method="resources/list",
|
||||||
params=types.PaginatedRequestParams(cursor=cursor)
|
params=types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None,
|
||||||
if cursor is not None
|
|
||||||
else None,
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
types.ListResourcesResult,
|
types.ListResourcesResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def list_resource_templates(
|
async def list_resource_templates(self, cursor: str | None = None) -> types.ListResourceTemplatesResult:
|
||||||
self, cursor: str | None = None
|
|
||||||
) -> types.ListResourceTemplatesResult:
|
|
||||||
"""Send a resources/templates/list request."""
|
"""Send a resources/templates/list request."""
|
||||||
return await self.send_request(
|
return await self.send_request(
|
||||||
types.ClientRequest(
|
types.ClientRequest(
|
||||||
types.ListResourceTemplatesRequest(
|
types.ListResourceTemplatesRequest(
|
||||||
method="resources/templates/list",
|
method="resources/templates/list",
|
||||||
params=types.PaginatedRequestParams(cursor=cursor)
|
params=types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None,
|
||||||
if cursor is not None
|
|
||||||
else None,
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
types.ListResourceTemplatesResult,
|
types.ListResourceTemplatesResult,
|
||||||
|
|
@ -305,17 +282,13 @@ class ClientSession(
|
||||||
types.ClientRequest(
|
types.ClientRequest(
|
||||||
types.ListPromptsRequest(
|
types.ListPromptsRequest(
|
||||||
method="prompts/list",
|
method="prompts/list",
|
||||||
params=types.PaginatedRequestParams(cursor=cursor)
|
params=types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None,
|
||||||
if cursor is not None
|
|
||||||
else None,
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
types.ListPromptsResult,
|
types.ListPromptsResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_prompt(
|
async def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult:
|
||||||
self, name: str, arguments: dict[str, str] | None = None
|
|
||||||
) -> types.GetPromptResult:
|
|
||||||
"""Send a prompts/get request."""
|
"""Send a prompts/get request."""
|
||||||
return await self.send_request(
|
return await self.send_request(
|
||||||
types.ClientRequest(
|
types.ClientRequest(
|
||||||
|
|
@ -352,9 +325,7 @@ class ClientSession(
|
||||||
types.ClientRequest(
|
types.ClientRequest(
|
||||||
types.ListToolsRequest(
|
types.ListToolsRequest(
|
||||||
method="tools/list",
|
method="tools/list",
|
||||||
params=types.PaginatedRequestParams(cursor=cursor)
|
params=types.PaginatedRequestParams(cursor=cursor) if cursor is not None else None,
|
||||||
if cursor is not None
|
|
||||||
else None,
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
types.ListToolsResult,
|
types.ListToolsResult,
|
||||||
|
|
@ -370,9 +341,7 @@ class ClientSession(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _received_request(
|
async def _received_request(self, responder: RequestResponder[types.ServerRequest, types.ClientResult]) -> None:
|
||||||
self, responder: RequestResponder[types.ServerRequest, types.ClientResult]
|
|
||||||
) -> None:
|
|
||||||
ctx = RequestContext[ClientSession, Any](
|
ctx = RequestContext[ClientSession, Any](
|
||||||
request_id=responder.request_id,
|
request_id=responder.request_id,
|
||||||
meta=responder.request_meta,
|
meta=responder.request_meta,
|
||||||
|
|
@ -395,22 +364,16 @@ class ClientSession(
|
||||||
|
|
||||||
case types.PingRequest():
|
case types.PingRequest():
|
||||||
with responder:
|
with responder:
|
||||||
return await responder.respond(
|
return await responder.respond(types.ClientResult(root=types.EmptyResult()))
|
||||||
types.ClientResult(root=types.EmptyResult())
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_incoming(
|
async def _handle_incoming(
|
||||||
self,
|
self,
|
||||||
req: RequestResponder[types.ServerRequest, types.ClientResult]
|
req: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
|
||||||
| types.ServerNotification
|
|
||||||
| Exception,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle incoming messages by forwarding to the message handler."""
|
"""Handle incoming messages by forwarding to the message handler."""
|
||||||
await self._message_handler(req)
|
await self._message_handler(req)
|
||||||
|
|
||||||
async def _received_notification(
|
async def _received_notification(self, notification: types.ServerNotification) -> None:
|
||||||
self, notification: types.ServerNotification
|
|
||||||
) -> None:
|
|
||||||
"""Handle notifications from the server."""
|
"""Handle notifications from the server."""
|
||||||
# Process specific notification types
|
# Process specific notification types
|
||||||
match notification.root:
|
match notification.root:
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,7 @@ class StreamableHttpParameters(BaseModel):
|
||||||
terminate_on_close: bool = True
|
terminate_on_close: bool = True
|
||||||
|
|
||||||
|
|
||||||
ServerParameters: TypeAlias = (
|
ServerParameters: TypeAlias = StdioServerParameters | SseServerParameters | StreamableHttpParameters
|
||||||
StdioServerParameters | SseServerParameters | StreamableHttpParameters
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClientSessionGroup:
|
class ClientSessionGroup:
|
||||||
|
|
@ -261,9 +259,7 @@ class ClientSessionGroup:
|
||||||
)
|
)
|
||||||
read, write, _ = await session_stack.enter_async_context(client)
|
read, write, _ = await session_stack.enter_async_context(client)
|
||||||
|
|
||||||
session = await session_stack.enter_async_context(
|
session = await session_stack.enter_async_context(mcp.ClientSession(read, write))
|
||||||
mcp.ClientSession(read, write)
|
|
||||||
)
|
|
||||||
result = await session.initialize()
|
result = await session.initialize()
|
||||||
|
|
||||||
# Session successfully initialized.
|
# Session successfully initialized.
|
||||||
|
|
@ -280,9 +276,7 @@ class ClientSessionGroup:
|
||||||
await session_stack.aclose()
|
await session_stack.aclose()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def _aggregate_components(
|
async def _aggregate_components(self, server_info: types.Implementation, session: mcp.ClientSession) -> None:
|
||||||
self, server_info: types.Implementation, session: mcp.ClientSession
|
|
||||||
) -> None:
|
|
||||||
"""Aggregates prompts, resources, and tools from a given session."""
|
"""Aggregates prompts, resources, and tools from a given session."""
|
||||||
|
|
||||||
# Create a reverse index so we can find all prompts, resources, and
|
# Create a reverse index so we can find all prompts, resources, and
|
||||||
|
|
|
||||||
|
|
@ -73,20 +73,16 @@ async def sse_client(
|
||||||
match sse.event:
|
match sse.event:
|
||||||
case "endpoint":
|
case "endpoint":
|
||||||
endpoint_url = urljoin(url, sse.data)
|
endpoint_url = urljoin(url, sse.data)
|
||||||
logger.debug(
|
logger.debug(f"Received endpoint URL: {endpoint_url}")
|
||||||
f"Received endpoint URL: {endpoint_url}"
|
|
||||||
)
|
|
||||||
|
|
||||||
url_parsed = urlparse(url)
|
url_parsed = urlparse(url)
|
||||||
endpoint_parsed = urlparse(endpoint_url)
|
endpoint_parsed = urlparse(endpoint_url)
|
||||||
if (
|
if (
|
||||||
url_parsed.netloc != endpoint_parsed.netloc
|
url_parsed.netloc != endpoint_parsed.netloc
|
||||||
or url_parsed.scheme
|
or url_parsed.scheme != endpoint_parsed.scheme
|
||||||
!= endpoint_parsed.scheme
|
|
||||||
):
|
):
|
||||||
error_msg = (
|
error_msg = (
|
||||||
"Endpoint origin does not match "
|
"Endpoint origin does not match " f"connection origin: {endpoint_url}"
|
||||||
f"connection origin: {endpoint_url}"
|
|
||||||
)
|
)
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise ValueError(error_msg)
|
raise ValueError(error_msg)
|
||||||
|
|
@ -98,22 +94,16 @@ async def sse_client(
|
||||||
message = types.JSONRPCMessage.model_validate_json( # noqa: E501
|
message = types.JSONRPCMessage.model_validate_json( # noqa: E501
|
||||||
sse.data
|
sse.data
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(f"Received server message: {message}")
|
||||||
f"Received server message: {message}"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(f"Error parsing server message: {exc}")
|
||||||
f"Error parsing server message: {exc}"
|
|
||||||
)
|
|
||||||
await read_stream_writer.send(exc)
|
await read_stream_writer.send(exc)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
session_message = SessionMessage(message)
|
session_message = SessionMessage(message)
|
||||||
await read_stream_writer.send(session_message)
|
await read_stream_writer.send(session_message)
|
||||||
case _:
|
case _:
|
||||||
logger.warning(
|
logger.warning(f"Unknown SSE event: {sse.event}")
|
||||||
f"Unknown SSE event: {sse.event}"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"Error in sse_reader: {exc}")
|
logger.error(f"Error in sse_reader: {exc}")
|
||||||
await read_stream_writer.send(exc)
|
await read_stream_writer.send(exc)
|
||||||
|
|
@ -124,9 +114,7 @@ async def sse_client(
|
||||||
try:
|
try:
|
||||||
async with write_stream_reader:
|
async with write_stream_reader:
|
||||||
async for session_message in write_stream_reader:
|
async for session_message in write_stream_reader:
|
||||||
logger.debug(
|
logger.debug(f"Sending client message: {session_message}")
|
||||||
f"Sending client message: {session_message}"
|
|
||||||
)
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
endpoint_url,
|
endpoint_url,
|
||||||
json=session_message.message.model_dump(
|
json=session_message.message.model_dump(
|
||||||
|
|
@ -136,19 +124,14 @@ async def sse_client(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
logger.debug(
|
logger.debug("Client message sent successfully: " f"{response.status_code}")
|
||||||
"Client message sent successfully: "
|
|
||||||
f"{response.status_code}"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"Error in post_writer: {exc}")
|
logger.error(f"Error in post_writer: {exc}")
|
||||||
finally:
|
finally:
|
||||||
await write_stream.aclose()
|
await write_stream.aclose()
|
||||||
|
|
||||||
endpoint_url = await tg.start(sse_reader)
|
endpoint_url = await tg.start(sse_reader)
|
||||||
logger.debug(
|
logger.debug(f"Starting post writer with endpoint URL: {endpoint_url}")
|
||||||
f"Starting post writer with endpoint URL: {endpoint_url}"
|
|
||||||
)
|
|
||||||
tg.start_soon(post_writer, endpoint_url)
|
tg.start_soon(post_writer, endpoint_url)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -115,11 +115,7 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
|
||||||
process = await _create_platform_compatible_process(
|
process = await _create_platform_compatible_process(
|
||||||
command=command,
|
command=command,
|
||||||
args=server.args,
|
args=server.args,
|
||||||
env=(
|
env=({**get_default_environment(), **server.env} if server.env is not None else get_default_environment()),
|
||||||
{**get_default_environment(), **server.env}
|
|
||||||
if server.env is not None
|
|
||||||
else get_default_environment()
|
|
||||||
),
|
|
||||||
errlog=errlog,
|
errlog=errlog,
|
||||||
cwd=server.cwd,
|
cwd=server.cwd,
|
||||||
)
|
)
|
||||||
|
|
@ -163,9 +159,7 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
|
||||||
try:
|
try:
|
||||||
async with write_stream_reader:
|
async with write_stream_reader:
|
||||||
async for session_message in write_stream_reader:
|
async for session_message in write_stream_reader:
|
||||||
json = session_message.message.model_dump_json(
|
json = session_message.message.model_dump_json(by_alias=True, exclude_none=True)
|
||||||
by_alias=True, exclude_none=True
|
|
||||||
)
|
|
||||||
await process.stdin.send(
|
await process.stdin.send(
|
||||||
(json + "\n").encode(
|
(json + "\n").encode(
|
||||||
encoding=server.encoding,
|
encoding=server.encoding,
|
||||||
|
|
@ -229,8 +223,6 @@ async def _create_platform_compatible_process(
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
process = await create_windows_process(command, args, env, errlog, cwd)
|
process = await create_windows_process(command, args, env, errlog, cwd)
|
||||||
else:
|
else:
|
||||||
process = await anyio.open_process(
|
process = await anyio.open_process([command, *args], env=env, stderr=errlog, cwd=cwd)
|
||||||
[command, *args], env=env, stderr=errlog, cwd=cwd
|
|
||||||
)
|
|
||||||
|
|
||||||
return process
|
return process
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,7 @@ async def create_windows_process(
|
||||||
return process
|
return process
|
||||||
except Exception:
|
except Exception:
|
||||||
# Don't raise, let's try to create the process without creation flags
|
# Don't raise, let's try to create the process without creation flags
|
||||||
process = await anyio.open_process(
|
process = await anyio.open_process([command, *args], env=env, stderr=errlog, cwd=cwd)
|
||||||
[command, *args], env=env, stderr=errlog, cwd=cwd
|
|
||||||
)
|
|
||||||
return process
|
return process
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,9 +106,7 @@ class StreamableHTTPTransport:
|
||||||
**self.headers,
|
**self.headers,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _update_headers_with_session(
|
def _update_headers_with_session(self, base_headers: dict[str, str]) -> dict[str, str]:
|
||||||
self, base_headers: dict[str, str]
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Update headers with session ID if available."""
|
"""Update headers with session ID if available."""
|
||||||
headers = base_headers.copy()
|
headers = base_headers.copy()
|
||||||
if self.session_id:
|
if self.session_id:
|
||||||
|
|
@ -117,17 +115,11 @@ class StreamableHTTPTransport:
|
||||||
|
|
||||||
def _is_initialization_request(self, message: JSONRPCMessage) -> bool:
|
def _is_initialization_request(self, message: JSONRPCMessage) -> bool:
|
||||||
"""Check if the message is an initialization request."""
|
"""Check if the message is an initialization request."""
|
||||||
return (
|
return isinstance(message.root, JSONRPCRequest) and message.root.method == "initialize"
|
||||||
isinstance(message.root, JSONRPCRequest)
|
|
||||||
and message.root.method == "initialize"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _is_initialized_notification(self, message: JSONRPCMessage) -> bool:
|
def _is_initialized_notification(self, message: JSONRPCMessage) -> bool:
|
||||||
"""Check if the message is an initialized notification."""
|
"""Check if the message is an initialized notification."""
|
||||||
return (
|
return isinstance(message.root, JSONRPCNotification) and message.root.method == "notifications/initialized"
|
||||||
isinstance(message.root, JSONRPCNotification)
|
|
||||||
and message.root.method == "notifications/initialized"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _maybe_extract_session_id_from_response(
|
def _maybe_extract_session_id_from_response(
|
||||||
self,
|
self,
|
||||||
|
|
@ -153,9 +145,7 @@ class StreamableHTTPTransport:
|
||||||
logger.debug(f"SSE message: {message}")
|
logger.debug(f"SSE message: {message}")
|
||||||
|
|
||||||
# If this is a response and we have original_request_id, replace it
|
# If this is a response and we have original_request_id, replace it
|
||||||
if original_request_id is not None and isinstance(
|
if original_request_id is not None and isinstance(message.root, JSONRPCResponse | JSONRPCError):
|
||||||
message.root, JSONRPCResponse | JSONRPCError
|
|
||||||
):
|
|
||||||
message.root.id = original_request_id
|
message.root.id = original_request_id
|
||||||
|
|
||||||
session_message = SessionMessage(message)
|
session_message = SessionMessage(message)
|
||||||
|
|
@ -227,7 +217,8 @@ class StreamableHTTPTransport:
|
||||||
self.url,
|
self.url,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=httpx.Timeout(
|
timeout=httpx.Timeout(
|
||||||
self.timeout.total_seconds(), read=ctx.sse_read_timeout.total_seconds()
|
self.timeout.total_seconds(),
|
||||||
|
read=ctx.sse_read_timeout.total_seconds(),
|
||||||
),
|
),
|
||||||
) as event_source:
|
) as event_source:
|
||||||
event_source.response.raise_for_status()
|
event_source.response.raise_for_status()
|
||||||
|
|
@ -298,9 +289,7 @@ class StreamableHTTPTransport:
|
||||||
logger.error(f"Error parsing JSON response: {exc}")
|
logger.error(f"Error parsing JSON response: {exc}")
|
||||||
await read_stream_writer.send(exc)
|
await read_stream_writer.send(exc)
|
||||||
|
|
||||||
async def _handle_sse_response(
|
async def _handle_sse_response(self, response: httpx.Response, ctx: RequestContext) -> None:
|
||||||
self, response: httpx.Response, ctx: RequestContext
|
|
||||||
) -> None:
|
|
||||||
"""Handle SSE response from the server."""
|
"""Handle SSE response from the server."""
|
||||||
try:
|
try:
|
||||||
event_source = EventSource(response)
|
event_source = EventSource(response)
|
||||||
|
|
@ -308,11 +297,7 @@ class StreamableHTTPTransport:
|
||||||
is_complete = await self._handle_sse_event(
|
is_complete = await self._handle_sse_event(
|
||||||
sse,
|
sse,
|
||||||
ctx.read_stream_writer,
|
ctx.read_stream_writer,
|
||||||
resumption_callback=(
|
resumption_callback=(ctx.metadata.on_resumption_token_update if ctx.metadata else None),
|
||||||
ctx.metadata.on_resumption_token_update
|
|
||||||
if ctx.metadata
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
# If the SSE event indicates completion, like returning respose/error
|
# If the SSE event indicates completion, like returning respose/error
|
||||||
# break the loop
|
# break the loop
|
||||||
|
|
@ -455,12 +440,8 @@ async def streamablehttp_client(
|
||||||
"""
|
"""
|
||||||
transport = StreamableHTTPTransport(url, headers, timeout, sse_read_timeout, auth)
|
transport = StreamableHTTPTransport(url, headers, timeout, sse_read_timeout, auth)
|
||||||
|
|
||||||
read_stream_writer, read_stream = anyio.create_memory_object_stream[
|
read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](0)
|
||||||
SessionMessage | Exception
|
write_stream, write_stream_reader = anyio.create_memory_object_stream[SessionMessage](0)
|
||||||
](0)
|
|
||||||
write_stream, write_stream_reader = anyio.create_memory_object_stream[
|
|
||||||
SessionMessage
|
|
||||||
](0)
|
|
||||||
|
|
||||||
async with anyio.create_task_group() as tg:
|
async with anyio.create_task_group() as tg:
|
||||||
try:
|
try:
|
||||||
|
|
@ -476,9 +457,7 @@ async def streamablehttp_client(
|
||||||
) as client:
|
) as client:
|
||||||
# Define callbacks that need access to tg
|
# Define callbacks that need access to tg
|
||||||
def start_get_stream() -> None:
|
def start_get_stream() -> None:
|
||||||
tg.start_soon(
|
tg.start_soon(transport.handle_get_stream, client, read_stream_writer)
|
||||||
transport.handle_get_stream, client, read_stream_writer
|
|
||||||
)
|
|
||||||
|
|
||||||
tg.start_soon(
|
tg.start_soon(
|
||||||
transport.post_writer,
|
transport.post_writer,
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,7 @@ logger = logging.getLogger(__name__)
|
||||||
async def websocket_client(
|
async def websocket_client(
|
||||||
url: str,
|
url: str,
|
||||||
) -> AsyncGenerator[
|
) -> AsyncGenerator[
|
||||||
tuple[
|
tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]],
|
||||||
MemoryObjectReceiveStream[SessionMessage | Exception],
|
|
||||||
MemoryObjectSendStream[SessionMessage],
|
|
||||||
],
|
|
||||||
None,
|
None,
|
||||||
]:
|
]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -74,9 +71,7 @@ async def websocket_client(
|
||||||
async with write_stream_reader:
|
async with write_stream_reader:
|
||||||
async for session_message in write_stream_reader:
|
async for session_message in write_stream_reader:
|
||||||
# Convert to a dict, then to JSON
|
# Convert to a dict, then to JSON
|
||||||
msg_dict = session_message.message.model_dump(
|
msg_dict = session_message.message.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||||
by_alias=True, mode="json", exclude_none=True
|
|
||||||
)
|
|
||||||
await ws.send(json.dumps(msg_dict))
|
await ws.send(json.dumps(msg_dict))
|
||||||
|
|
||||||
async with anyio.create_task_group() as tg:
|
async with anyio.create_task_group() as tg:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,4 @@ from pydantic import ValidationError
|
||||||
|
|
||||||
|
|
||||||
def stringify_pydantic_error(validation_error: ValidationError) -> str:
|
def stringify_pydantic_error(validation_error: ValidationError) -> str:
|
||||||
return "\n".join(
|
return "\n".join(f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" for e in validation_error.errors())
|
||||||
f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}"
|
|
||||||
for e in validation_error.errors()
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,7 @@ from starlette.datastructures import FormData, QueryParams
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import RedirectResponse, Response
|
from starlette.responses import RedirectResponse, Response
|
||||||
|
|
||||||
from mcp.server.auth.errors import (
|
from mcp.server.auth.errors import stringify_pydantic_error
|
||||||
stringify_pydantic_error,
|
|
||||||
)
|
|
||||||
from mcp.server.auth.json_response import PydanticJSONResponse
|
from mcp.server.auth.json_response import PydanticJSONResponse
|
||||||
from mcp.server.auth.provider import (
|
from mcp.server.auth.provider import (
|
||||||
AuthorizationErrorCode,
|
AuthorizationErrorCode,
|
||||||
|
|
@ -18,10 +16,7 @@ from mcp.server.auth.provider import (
|
||||||
OAuthAuthorizationServerProvider,
|
OAuthAuthorizationServerProvider,
|
||||||
construct_redirect_uri,
|
construct_redirect_uri,
|
||||||
)
|
)
|
||||||
from mcp.shared.auth import (
|
from mcp.shared.auth import InvalidRedirectUriError, InvalidScopeError
|
||||||
InvalidRedirectUriError,
|
|
||||||
InvalidScopeError,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -29,23 +24,16 @@ logger = logging.getLogger(__name__)
|
||||||
class AuthorizationRequest(BaseModel):
|
class AuthorizationRequest(BaseModel):
|
||||||
# See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
|
# See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
|
||||||
client_id: str = Field(..., description="The client ID")
|
client_id: str = Field(..., description="The client ID")
|
||||||
redirect_uri: AnyUrl | None = Field(
|
redirect_uri: AnyUrl | None = Field(None, description="URL to redirect to after authorization")
|
||||||
None, description="URL to redirect to after authorization"
|
|
||||||
)
|
|
||||||
|
|
||||||
# see OAuthClientMetadata; we only support `code`
|
# see OAuthClientMetadata; we only support `code`
|
||||||
response_type: Literal["code"] = Field(
|
response_type: Literal["code"] = Field(..., description="Must be 'code' for authorization code flow")
|
||||||
..., description="Must be 'code' for authorization code flow"
|
|
||||||
)
|
|
||||||
code_challenge: str = Field(..., description="PKCE code challenge")
|
code_challenge: str = Field(..., description="PKCE code challenge")
|
||||||
code_challenge_method: Literal["S256"] = Field(
|
code_challenge_method: Literal["S256"] = Field("S256", description="PKCE code challenge method, must be S256")
|
||||||
"S256", description="PKCE code challenge method, must be S256"
|
|
||||||
)
|
|
||||||
state: str | None = Field(None, description="Optional state parameter")
|
state: str | None = Field(None, description="Optional state parameter")
|
||||||
scope: str | None = Field(
|
scope: str | None = Field(
|
||||||
None,
|
None,
|
||||||
description="Optional scope; if specified, should be "
|
description="Optional scope; if specified, should be " "a space-separated list of scope strings",
|
||||||
"a space-separated list of scope strings",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -57,9 +45,7 @@ class AuthorizationErrorResponse(BaseModel):
|
||||||
state: str | None = None
|
state: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def best_effort_extract_string(
|
def best_effort_extract_string(key: str, params: None | FormData | QueryParams) -> str | None:
|
||||||
key: str, params: None | FormData | QueryParams
|
|
||||||
) -> str | None:
|
|
||||||
if params is None:
|
if params is None:
|
||||||
return None
|
return None
|
||||||
value = params.get(key)
|
value = params.get(key)
|
||||||
|
|
@ -138,9 +124,7 @@ class AuthorizationHandler:
|
||||||
|
|
||||||
if redirect_uri and client:
|
if redirect_uri and client:
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url=construct_redirect_uri(
|
url=construct_redirect_uri(str(redirect_uri), **error_resp.model_dump(exclude_none=True)),
|
||||||
str(redirect_uri), **error_resp.model_dump(exclude_none=True)
|
|
||||||
),
|
|
||||||
status_code=302,
|
status_code=302,
|
||||||
headers={"Cache-Control": "no-store"},
|
headers={"Cache-Control": "no-store"},
|
||||||
)
|
)
|
||||||
|
|
@ -172,9 +156,7 @@ class AuthorizationHandler:
|
||||||
if e["loc"] == ("response_type",) and e["type"] == "literal_error":
|
if e["loc"] == ("response_type",) and e["type"] == "literal_error":
|
||||||
error = "unsupported_response_type"
|
error = "unsupported_response_type"
|
||||||
break
|
break
|
||||||
return await error_response(
|
return await error_response(error, stringify_pydantic_error(validation_error))
|
||||||
error, stringify_pydantic_error(validation_error)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get client information
|
# Get client information
|
||||||
client = await self.provider.get_client(
|
client = await self.provider.get_client(
|
||||||
|
|
@ -229,16 +211,9 @@ class AuthorizationHandler:
|
||||||
)
|
)
|
||||||
except AuthorizeError as e:
|
except AuthorizeError as e:
|
||||||
# Handle authorization errors as defined in RFC 6749 Section 4.1.2.1
|
# Handle authorization errors as defined in RFC 6749 Section 4.1.2.1
|
||||||
return await error_response(
|
return await error_response(error=e.error, error_description=e.error_description)
|
||||||
error=e.error,
|
|
||||||
error_description=e.error_description,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as validation_error:
|
except Exception as validation_error:
|
||||||
# Catch-all for unexpected errors
|
# Catch-all for unexpected errors
|
||||||
logger.exception(
|
logger.exception("Unexpected error in authorization_handler", exc_info=validation_error)
|
||||||
"Unexpected error in authorization_handler", exc_info=validation_error
|
return await error_response(error="server_error", error_description="An unexpected error occurred")
|
||||||
)
|
|
||||||
return await error_response(
|
|
||||||
error="server_error", error_description="An unexpected error occurred"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,7 @@ from starlette.responses import Response
|
||||||
|
|
||||||
from mcp.server.auth.errors import stringify_pydantic_error
|
from mcp.server.auth.errors import stringify_pydantic_error
|
||||||
from mcp.server.auth.json_response import PydanticJSONResponse
|
from mcp.server.auth.json_response import PydanticJSONResponse
|
||||||
from mcp.server.auth.provider import (
|
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, RegistrationError, RegistrationErrorCode
|
||||||
OAuthAuthorizationServerProvider,
|
|
||||||
RegistrationError,
|
|
||||||
RegistrationErrorCode,
|
|
||||||
)
|
|
||||||
from mcp.server.auth.settings import ClientRegistrationOptions
|
from mcp.server.auth.settings import ClientRegistrationOptions
|
||||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
|
||||||
|
|
||||||
|
|
@ -60,9 +56,7 @@ class RegistrationHandler:
|
||||||
|
|
||||||
if client_metadata.scope is None and self.options.default_scopes is not None:
|
if client_metadata.scope is None and self.options.default_scopes is not None:
|
||||||
client_metadata.scope = " ".join(self.options.default_scopes)
|
client_metadata.scope = " ".join(self.options.default_scopes)
|
||||||
elif (
|
elif client_metadata.scope is not None and self.options.valid_scopes is not None:
|
||||||
client_metadata.scope is not None and self.options.valid_scopes is not None
|
|
||||||
):
|
|
||||||
requested_scopes = set(client_metadata.scope.split())
|
requested_scopes = set(client_metadata.scope.split())
|
||||||
valid_scopes = set(self.options.valid_scopes)
|
valid_scopes = set(self.options.valid_scopes)
|
||||||
if not requested_scopes.issubset(valid_scopes):
|
if not requested_scopes.issubset(valid_scopes):
|
||||||
|
|
@ -78,8 +72,7 @@ class RegistrationHandler:
|
||||||
return PydanticJSONResponse(
|
return PydanticJSONResponse(
|
||||||
content=RegistrationErrorResponse(
|
content=RegistrationErrorResponse(
|
||||||
error="invalid_client_metadata",
|
error="invalid_client_metadata",
|
||||||
error_description="grant_types must be authorization_code "
|
error_description="grant_types must be authorization_code " "and refresh_token",
|
||||||
"and refresh_token",
|
|
||||||
),
|
),
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
@ -122,8 +115,6 @@ class RegistrationHandler:
|
||||||
except RegistrationError as e:
|
except RegistrationError as e:
|
||||||
# Handle registration errors as defined in RFC 7591 Section 3.2.2
|
# Handle registration errors as defined in RFC 7591 Section 3.2.2
|
||||||
return PydanticJSONResponse(
|
return PydanticJSONResponse(
|
||||||
content=RegistrationErrorResponse(
|
content=RegistrationErrorResponse(error=e.error, error_description=e.error_description),
|
||||||
error=e.error, error_description=e.error_description
|
|
||||||
),
|
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,8 @@ from mcp.server.auth.errors import (
|
||||||
stringify_pydantic_error,
|
stringify_pydantic_error,
|
||||||
)
|
)
|
||||||
from mcp.server.auth.json_response import PydanticJSONResponse
|
from mcp.server.auth.json_response import PydanticJSONResponse
|
||||||
from mcp.server.auth.middleware.client_auth import (
|
from mcp.server.auth.middleware.client_auth import AuthenticationError, ClientAuthenticator
|
||||||
AuthenticationError,
|
from mcp.server.auth.provider import AccessToken, OAuthAuthorizationServerProvider, RefreshToken
|
||||||
ClientAuthenticator,
|
|
||||||
)
|
|
||||||
from mcp.server.auth.provider import (
|
|
||||||
AccessToken,
|
|
||||||
OAuthAuthorizationServerProvider,
|
|
||||||
RefreshToken,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RevocationRequest(BaseModel):
|
class RevocationRequest(BaseModel):
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue