mcp-apollo 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mcp_apollo/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Apollo MCP Server
2
+
3
+ 提供与 Apollo 配置中心交互的 MCP 工具。
4
+ """
5
+
6
+ __version__ = "0.1.0"
mcp_apollo/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """允许通过 python -m mcp_apollo 运行"""
2
+
3
+ from .server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
mcp_apollo/auth.py ADDED
@@ -0,0 +1,109 @@
1
+ """HTTP 传输的简单 Bearer Token 认证中间件(纯 ASGI 实现)。
2
+
3
+ 仅用于保护 sse / streamable-http 两种 HTTP 传输的接口,stdio 传输不受影响。
4
+ 认证方式:请求头携带 `Authorization: Bearer <token>`,
5
+ 兼容 `X-Auth-Token: <token>` 与 `X-MCP-Token: <token>`。
6
+
7
+ 设计为纯 ASGI 中间件,避免依赖具体版本的 Starlette 中间件 API,
8
+ 可直接包裹 FastMCP 返回的 sse_app() / streamable_http_app()。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import Any, Awaitable, Callable
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ Scope = dict[str, Any]
19
+ Message = dict[str, Any]
20
+ Receive = Callable[[], Awaitable[Message]]
21
+ Send = Callable[[Message], Awaitable[None]]
22
+ ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
23
+
24
+
25
+ class TokenAuthMiddleware:
26
+ """基于固定 Token 的 ASGI 认证中间件。
27
+
28
+ - 非 HTTP 请求(如 lifespan、websocket)直接放行,保证应用生命周期正常。
29
+ - 健康检查路径(默认 /health)免鉴权,方便容器探活。
30
+ - 其余 HTTP 请求必须携带正确 Token,否则返回 401。
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ app: ASGIApp,
36
+ token: str,
37
+ health_path: str = "/health",
38
+ ) -> None:
39
+ self.app = app
40
+ self._token = token
41
+ self._health_path = health_path
42
+
43
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
44
+ if scope.get("type") != "http":
45
+ # lifespan / websocket 等直接透传,确保 session manager 生命周期正常
46
+ await self.app(scope, receive, send)
47
+ return
48
+
49
+ path = scope.get("path", "")
50
+ if path == self._health_path:
51
+ await self._respond_ok(send)
52
+ return
53
+
54
+ if not self._is_authorized(scope):
55
+ await self._respond_unauthorized(send)
56
+ return
57
+
58
+ await self.app(scope, receive, send)
59
+
60
+ def _is_authorized(self, scope: Scope) -> bool:
61
+ headers = {k.lower(): v for k, v in scope.get("headers", [])}
62
+
63
+ auth = headers.get(b"authorization", b"").decode("latin-1").strip()
64
+ if auth.lower().startswith("bearer "):
65
+ if _constant_equals(auth[7:].strip(), self._token):
66
+ return True
67
+
68
+ for name in (b"x-auth-token", b"x-mcp-token"):
69
+ value = headers.get(name, b"").decode("latin-1").strip()
70
+ if value and _constant_equals(value, self._token):
71
+ return True
72
+
73
+ return False
74
+
75
+ async def _respond_unauthorized(self, send: Send) -> None:
76
+ body = b'{"error":"unauthorized","message":"missing or invalid MCP auth token"}'
77
+ await send(
78
+ {
79
+ "type": "http.response.start",
80
+ "status": 401,
81
+ "headers": [
82
+ (b"content-type", b"application/json"),
83
+ (b"www-authenticate", b'Bearer realm="mcp"'),
84
+ (b"content-length", str(len(body)).encode("latin-1")),
85
+ ],
86
+ }
87
+ )
88
+ await send({"type": "http.response.body", "body": body})
89
+
90
+ async def _respond_ok(self, send: Send) -> None:
91
+ body = b'{"status":"ok"}'
92
+ await send(
93
+ {
94
+ "type": "http.response.start",
95
+ "status": 200,
96
+ "headers": [
97
+ (b"content-type", b"application/json"),
98
+ (b"content-length", str(len(body)).encode("latin-1")),
99
+ ],
100
+ }
101
+ )
102
+ await send({"type": "http.response.body", "body": body})
103
+
104
+
105
+ def _constant_equals(a: str, b: str) -> bool:
106
+ """常量时间比较,降低时序攻击风险。"""
107
+ import hmac
108
+
109
+ return hmac.compare_digest(a, b)
mcp_apollo/client.py ADDED
@@ -0,0 +1,5 @@
1
+ """Apollo 客户端导出"""
2
+
3
+ from .clients import get_apollo_client
4
+
5
+ __all__ = ["get_apollo_client"]
@@ -0,0 +1,6 @@
1
+ """Apollo 客户端实现"""
2
+
3
+ from .base import ApolloClientProtocol
4
+ from .factory import get_apollo_client
5
+
6
+ __all__ = ["ApolloClientProtocol", "get_apollo_client"]
@@ -0,0 +1,231 @@
1
+ """Apollo 客户端基础实现"""
2
+
3
+ import os
4
+ from typing import Any, Optional, Protocol
5
+
6
+
7
+ class ApolloClientProtocol(Protocol):
8
+ """Apollo 客户端协议"""
9
+
10
+ default_env: str
11
+ default_app_id: str
12
+ default_cluster: str
13
+ default_namespace: str
14
+
15
+ # ---- 读取/查询(只读,对应 3.2 查询类接口)----
16
+ async def get_config(
17
+ self,
18
+ namespace_name: Optional[str] = None,
19
+ key: Optional[str] = None,
20
+ env: Optional[str] = None,
21
+ app_id: Optional[str] = None,
22
+ cluster_name: Optional[str] = None,
23
+ page: Optional[int] = None,
24
+ page_size: Optional[int] = None,
25
+ ) -> dict[str, Any]: ...
26
+
27
+ async def get_app_env_clusters(
28
+ self,
29
+ app_id: Optional[str] = None,
30
+ env: Optional[str] = None,
31
+ ) -> list[dict[str, Any]]: ...
32
+
33
+ async def get_apps(
34
+ self,
35
+ app_ids: Optional[str] = None,
36
+ ) -> list[dict[str, Any]]: ...
37
+
38
+ async def get_cluster(
39
+ self,
40
+ env: Optional[str] = None,
41
+ app_id: Optional[str] = None,
42
+ cluster_name: Optional[str] = None,
43
+ ) -> dict[str, Any]: ...
44
+
45
+ async def list_namespaces(
46
+ self,
47
+ env: Optional[str] = None,
48
+ app_id: Optional[str] = None,
49
+ cluster_name: Optional[str] = None,
50
+ ) -> list[dict[str, Any]]: ...
51
+
52
+ async def get_namespace_lock(
53
+ self,
54
+ env: Optional[str] = None,
55
+ app_id: Optional[str] = None,
56
+ cluster_name: Optional[str] = None,
57
+ namespace_name: Optional[str] = None,
58
+ ) -> dict[str, Any]: ...
59
+
60
+ async def get_latest_release(
61
+ self,
62
+ env: Optional[str] = None,
63
+ app_id: Optional[str] = None,
64
+ cluster_name: Optional[str] = None,
65
+ namespace_name: Optional[str] = None,
66
+ ) -> dict[str, Any]: ...
67
+
68
+ async def list_items(
69
+ self,
70
+ env: Optional[str] = None,
71
+ app_id: Optional[str] = None,
72
+ cluster_name: Optional[str] = None,
73
+ namespace_name: Optional[str] = None,
74
+ page: Optional[int] = None,
75
+ size: Optional[int] = None,
76
+ ) -> dict[str, Any]: ...
77
+
78
+ # ---- 写入/变更(写,对应 3.2 变更类接口)----
79
+ async def create_item(
80
+ self,
81
+ key: str,
82
+ value: str,
83
+ namespace_name: Optional[str] = None,
84
+ env: Optional[str] = None,
85
+ app_id: Optional[str] = None,
86
+ cluster_name: Optional[str] = None,
87
+ comment: Optional[str] = None,
88
+ ) -> dict[str, Any]: ...
89
+
90
+ async def update_item(
91
+ self,
92
+ key: str,
93
+ value: str,
94
+ namespace_name: Optional[str] = None,
95
+ env: Optional[str] = None,
96
+ app_id: Optional[str] = None,
97
+ cluster_name: Optional[str] = None,
98
+ comment: Optional[str] = None,
99
+ create_if_not_exists: bool = False,
100
+ ) -> dict[str, Any]: ...
101
+
102
+ async def releases(
103
+ self,
104
+ namespace_name: Optional[str] = None,
105
+ env: Optional[str] = None,
106
+ app_id: Optional[str] = None,
107
+ cluster_name: Optional[str] = None,
108
+ release_title: Optional[str] = None,
109
+ release_comment: Optional[str] = None,
110
+ ) -> dict[str, Any]: ...
111
+
112
+ async def create_cluster(
113
+ self,
114
+ name: str,
115
+ env: Optional[str] = None,
116
+ app_id: Optional[str] = None,
117
+ ) -> dict[str, Any]: ...
118
+
119
+ async def create_namespace(
120
+ self,
121
+ name: str,
122
+ format: str,
123
+ is_public: bool,
124
+ app_id: Optional[str] = None,
125
+ comment: Optional[str] = None,
126
+ ) -> dict[str, Any]: ...
127
+
128
+ async def delete_item(
129
+ self,
130
+ key: str,
131
+ env: Optional[str] = None,
132
+ app_id: Optional[str] = None,
133
+ cluster_name: Optional[str] = None,
134
+ namespace_name: Optional[str] = None,
135
+ ) -> dict[str, Any]: ...
136
+
137
+ async def rollback_release(
138
+ self,
139
+ release_id: int,
140
+ env: Optional[str] = None,
141
+ ) -> dict[str, Any]: ...
142
+
143
+ async def create_app(
144
+ self,
145
+ name: str,
146
+ app_id: str,
147
+ org_id: str,
148
+ org_name: str,
149
+ owner_name: str,
150
+ owner_email: str,
151
+ admins: Optional[list[str]] = None,
152
+ assign_app_role_to_self: bool = True,
153
+ ) -> dict[str, Any]: ...
154
+
155
+
156
+ class ApolloOpenApiBase:
157
+ """Apollo OpenAPI 客户端基类。
158
+
159
+ 负责从环境变量读取连接信息、组装认证头、以及把「工具参数 > 环境变量默认值」
160
+ 的优先级解析逻辑集中在一处。
161
+ """
162
+
163
+ def __init__(self) -> None:
164
+ self.portal_url = os.getenv("APOLLO_PORTAL_URL", "http://localhost:8070").rstrip("/")
165
+ self.token = os.getenv("APOLLO_TOKEN", "")
166
+ self.operator = os.getenv("APOLLO_OPERATOR", "apollo")
167
+
168
+ self.default_env = os.getenv("APOLLO_ENV", "DEV")
169
+ self.default_app_id = os.getenv("APOLLO_APP_ID", "")
170
+ self.default_cluster = os.getenv("APOLLO_CLUSTER", "default")
171
+ self.default_namespace = os.getenv("APOLLO_NAMESPACE", "application")
172
+
173
+ @property
174
+ def auth_headers(self) -> dict[str, str]:
175
+ """OpenAPI 认证头。
176
+
177
+ Apollo OpenAPI 使用第三方应用 Token,直接放在 Authorization 头(不带 Bearer 前缀),
178
+ 写操作要求 Content-Type 为 application/json;charset=UTF-8。
179
+ """
180
+ headers = {"Content-Type": "application/json;charset=UTF-8"}
181
+ if self.token:
182
+ headers["Authorization"] = self.token
183
+ return headers
184
+
185
+ def _resolve_env(self, env: Optional[str]) -> str:
186
+ return env or self.default_env
187
+
188
+ def _resolve_app_id(self, app_id: Optional[str]) -> str:
189
+ resolved = app_id or self.default_app_id
190
+ if not resolved:
191
+ raise ValueError("缺少 appId,请通过工具参数 app_id 或环境变量 APOLLO_APP_ID 指定")
192
+ return resolved
193
+
194
+ def _resolve_cluster(self, cluster_name: Optional[str]) -> str:
195
+ return cluster_name or self.default_cluster
196
+
197
+ def _resolve_namespace(self, namespace_name: Optional[str]) -> str:
198
+ return namespace_name or self.default_namespace
199
+
200
+ # ---- URL 构造辅助 ----
201
+ def _apps_base_url(self) -> str:
202
+ """/openapi/v1/apps —— 应用级(不带 env)"""
203
+ return f"{self.portal_url}/openapi/v1/apps"
204
+
205
+ def _env_apps_base_url(self, env: str, app_id: str) -> str:
206
+ """/openapi/v1/envs/{env}/apps/{appId}"""
207
+ return f"{self.portal_url}/openapi/v1/envs/{env}/apps/{app_id}"
208
+
209
+ def _cluster_base_url(self, env: str, app_id: str, cluster_name: str) -> str:
210
+ """/openapi/v1/envs/{env}/apps/{appId}/clusters/{clusterName}"""
211
+ return (
212
+ f"{self.portal_url}/openapi/v1/envs/{env}"
213
+ f"/apps/{app_id}/clusters/{cluster_name}"
214
+ )
215
+
216
+ def _namespace_base_url(
217
+ self, env: str, app_id: str, cluster_name: str, namespace_name: str
218
+ ) -> str:
219
+ """命名空间级别的 OpenAPI 基础 URL"""
220
+ return (
221
+ f"{self.portal_url}/openapi/v1/envs/{env}"
222
+ f"/apps/{app_id}/clusters/{cluster_name}/namespaces/{namespace_name}"
223
+ )
224
+
225
+ def _appnamespaces_base_url(self, app_id: str) -> str:
226
+ """/openapi/v1/apps/{appId}/appnamespaces —— 创建命名空间(不带 env)"""
227
+ return f"{self.portal_url}/openapi/v1/apps/{app_id}/appnamespaces"
228
+
229
+ def _releases_base_url(self, env: str) -> str:
230
+ """/openapi/v1/envs/{env}/releases —— 回滚等发布级操作"""
231
+ return f"{self.portal_url}/openapi/v1/envs/{env}/releases"
@@ -0,0 +1,23 @@
1
+ """Apollo 客户端工厂"""
2
+
3
+ from typing import Optional
4
+
5
+ from .base import ApolloClientProtocol
6
+ from .openapi import ApolloOpenApiClient
7
+
8
+ _cached_client: Optional[ApolloClientProtocol] = None
9
+
10
+
11
+ async def get_apollo_client() -> ApolloClientProtocol:
12
+ """获取 Apollo 客户端(基于 Portal OpenAPI)。
13
+
14
+ Apollo OpenAPI 接口稳定为 v1,无需按版本适配,
15
+ 因此工厂当前统一返回 OpenAPI 客户端,并保留工厂层以便未来扩展
16
+ (如新增只读的 Config Service 客户端)。
17
+ """
18
+ global _cached_client
19
+ if _cached_client:
20
+ return _cached_client
21
+
22
+ _cached_client = ApolloOpenApiClient()
23
+ return _cached_client