mcp-apollo-explorer 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_explorer/__init__.py +3 -0
- mcp_apollo_explorer/apollo_client.py +178 -0
- mcp_apollo_explorer/diff.py +220 -0
- mcp_apollo_explorer/server.py +202 -0
- mcp_apollo_explorer/xml_parent.py +117 -0
- mcp_apollo_explorer-0.1.0.dist-info/METADATA +7 -0
- mcp_apollo_explorer-0.1.0.dist-info/RECORD +9 -0
- mcp_apollo_explorer-0.1.0.dist-info/WHEEL +4 -0
- mcp_apollo_explorer-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Apollo Portal API client (cookie auth)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
DEFAULT_PORTAL_URL = "http://config-portal.beisencorp.com"
|
|
12
|
+
DEFAULT_ENV = "DEV"
|
|
13
|
+
DEFAULT_CLUSTER = "default"
|
|
14
|
+
PAGE_SIZE = 200
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApolloAuthError(RuntimeError):
|
|
18
|
+
"""Cookie 缺失或失效。"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _seg(value: Any) -> str:
|
|
22
|
+
"""URL-encode a single path segment."""
|
|
23
|
+
return quote(str(value), safe="")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _item_kv(i: dict) -> dict:
|
|
27
|
+
"""从 namespace/release 的 item 元素提取 {key, value}。
|
|
28
|
+
|
|
29
|
+
Apollo 对 xml/json/yaml 等"文件型" namespace,item 元素结构是
|
|
30
|
+
{item: {key, value, ...}, isModified, isDeleted};对 properties 是
|
|
31
|
+
直接的 {key, value, ...}。这里统一拍平到 {key, value}。
|
|
32
|
+
"""
|
|
33
|
+
inner = i.get("item")
|
|
34
|
+
if not isinstance(inner, dict):
|
|
35
|
+
inner = i
|
|
36
|
+
return {"key": inner.get("key"), "value": inner.get("value")}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ApolloClient:
|
|
40
|
+
"""通过 cookie 认证访问 Apollo Portal 管理 API。"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
portal_url: str | None = None,
|
|
45
|
+
cookie: str | None = None,
|
|
46
|
+
timeout: float = 15.0,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.portal_url = (
|
|
49
|
+
portal_url
|
|
50
|
+
or os.environ.get("APOLLO_PORTAL_URL")
|
|
51
|
+
or DEFAULT_PORTAL_URL
|
|
52
|
+
).rstrip("/")
|
|
53
|
+
self.cookie = (cookie or os.environ.get("APOLLO_COOKIE", "")).strip()
|
|
54
|
+
if not self.cookie:
|
|
55
|
+
raise ApolloAuthError("Missing APOLLO_COOKIE environment variable.")
|
|
56
|
+
self.timeout = timeout
|
|
57
|
+
|
|
58
|
+
def _headers(self) -> dict[str, str]:
|
|
59
|
+
return {"Cookie": self.cookie, "Accept": "application/json"}
|
|
60
|
+
|
|
61
|
+
def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
62
|
+
url = f"{self.portal_url}{path}"
|
|
63
|
+
try:
|
|
64
|
+
resp = httpx.get(
|
|
65
|
+
url,
|
|
66
|
+
headers=self._headers(),
|
|
67
|
+
params=params,
|
|
68
|
+
timeout=self.timeout,
|
|
69
|
+
follow_redirects=False,
|
|
70
|
+
)
|
|
71
|
+
except httpx.RequestError as exc:
|
|
72
|
+
raise RuntimeError(f"Apollo request failed: {exc}") from exc
|
|
73
|
+
|
|
74
|
+
if resp.status_code in (301, 302, 303, 307, 308):
|
|
75
|
+
raise ApolloAuthError(
|
|
76
|
+
"Apollo redirected (cookie likely expired). Re-export cookie from browser."
|
|
77
|
+
)
|
|
78
|
+
content_type = resp.headers.get("content-type", "")
|
|
79
|
+
text = resp.text
|
|
80
|
+
lowered = text[:4000].lower()
|
|
81
|
+
if (
|
|
82
|
+
"text/html" in content_type.lower()
|
|
83
|
+
and ("signin" in lowered or "login-submit" in lowered)
|
|
84
|
+
):
|
|
85
|
+
raise ApolloAuthError(
|
|
86
|
+
"Apollo returned login page (cookie expired). Re-export cookie from browser."
|
|
87
|
+
)
|
|
88
|
+
if resp.status_code >= 400:
|
|
89
|
+
raise RuntimeError(f"Apollo HTTP {resp.status_code}: {text[:200]}")
|
|
90
|
+
try:
|
|
91
|
+
return resp.json()
|
|
92
|
+
except ValueError:
|
|
93
|
+
return text
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _ns_name(ns: dict) -> str:
|
|
97
|
+
return (
|
|
98
|
+
(ns.get("baseInfo") or {}).get("namespaceName")
|
|
99
|
+
or ns.get("namespaceName")
|
|
100
|
+
or ns.get("name")
|
|
101
|
+
or ""
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def list_namespaces(
|
|
105
|
+
self, app_id: str, env: str, cluster: str
|
|
106
|
+
) -> list[dict]:
|
|
107
|
+
path = (
|
|
108
|
+
f"/apps/{_seg(app_id)}/envs/{_seg(env)}"
|
|
109
|
+
f"/clusters/{_seg(cluster)}/namespaces"
|
|
110
|
+
)
|
|
111
|
+
data = self._get(path)
|
|
112
|
+
if not isinstance(data, list):
|
|
113
|
+
raise RuntimeError(f"Unexpected namespace response: {str(data)[:200]}")
|
|
114
|
+
return data
|
|
115
|
+
|
|
116
|
+
def get_namespace_content(
|
|
117
|
+
self, app_id: str, namespace: str, env: str, cluster: str
|
|
118
|
+
) -> dict:
|
|
119
|
+
# 走单 namespace 详情接口,而非复用 list 接口:
|
|
120
|
+
# list 接口对 xml/json/yaml 等"文件型"namespace 不回填 items,
|
|
121
|
+
# 只有 /namespaces/{ns} 详情接口才返回完整配置内容。
|
|
122
|
+
path = (
|
|
123
|
+
f"/apps/{_seg(app_id)}/envs/{_seg(env)}/clusters/{_seg(cluster)}"
|
|
124
|
+
f"/namespaces/{_seg(namespace)}"
|
|
125
|
+
)
|
|
126
|
+
ns = self._get(path)
|
|
127
|
+
if not isinstance(ns, dict):
|
|
128
|
+
raise RuntimeError(f"Unexpected namespace response: {str(ns)[:200]}")
|
|
129
|
+
items = ns.get("items") or []
|
|
130
|
+
base = ns.get("baseInfo") or {}
|
|
131
|
+
fmt = ns.get("format") or base.get("format") or "properties"
|
|
132
|
+
return {
|
|
133
|
+
"app_id": app_id,
|
|
134
|
+
"namespace": namespace,
|
|
135
|
+
"env": env,
|
|
136
|
+
"cluster": cluster,
|
|
137
|
+
"format": fmt,
|
|
138
|
+
"items": [_item_kv(i) for i in items],
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def fetch_commits(
|
|
142
|
+
self,
|
|
143
|
+
app_id: str,
|
|
144
|
+
namespace: str,
|
|
145
|
+
env: str,
|
|
146
|
+
cluster: str,
|
|
147
|
+
since: str | None = None,
|
|
148
|
+
max_commits: int = 500,
|
|
149
|
+
) -> list[dict]:
|
|
150
|
+
"""分页拉提交历史,按时间倒序([0] 最新)。拉到覆盖 since 或拉完或达 max_commits。
|
|
151
|
+
|
|
152
|
+
commit 记录的是"改动人"的提交(dataChangeCreatedBy),与发布人分离。
|
|
153
|
+
"""
|
|
154
|
+
base = (
|
|
155
|
+
f"/apps/{_seg(app_id)}/envs/{_seg(env)}/clusters/{_seg(cluster)}"
|
|
156
|
+
f"/namespaces/{_seg(namespace)}/commits"
|
|
157
|
+
)
|
|
158
|
+
all_commits: list[dict] = []
|
|
159
|
+
page = 0
|
|
160
|
+
while page < 50:
|
|
161
|
+
data = self._get(base, params={"page": page, "size": PAGE_SIZE})
|
|
162
|
+
arr = (
|
|
163
|
+
data
|
|
164
|
+
if isinstance(data, list)
|
|
165
|
+
else (data.get("content") if isinstance(data, dict) else [])
|
|
166
|
+
)
|
|
167
|
+
if not arr:
|
|
168
|
+
break
|
|
169
|
+
all_commits.extend(arr)
|
|
170
|
+
if len(arr) < PAGE_SIZE:
|
|
171
|
+
break
|
|
172
|
+
if len(all_commits) >= max_commits:
|
|
173
|
+
break
|
|
174
|
+
oldest = arr[-1].get("dataChangeCreatedTime") or ""
|
|
175
|
+
if since and oldest and oldest < since:
|
|
176
|
+
break
|
|
177
|
+
page += 1
|
|
178
|
+
return all_commits
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Line-level diff using difflib, structured output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from mcp_apollo_explorer.xml_parent import parse_xml_tree, parent_paths_for_lines
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def diff_values(old: str | None, new: str | None) -> dict[str, Any]:
|
|
13
|
+
"""对比单个 value,返回 {type, old_lines, new_lines}。
|
|
14
|
+
|
|
15
|
+
type: added / removed / modified / unchanged
|
|
16
|
+
多行 value 用 SequenceMatcher 按行对比,只保留变化的行。
|
|
17
|
+
"""
|
|
18
|
+
old = "" if old is None else str(old)
|
|
19
|
+
new = "" if new is None else str(new)
|
|
20
|
+
if old == new:
|
|
21
|
+
return {"type": "unchanged", "old_lines": [], "new_lines": []}
|
|
22
|
+
if not old and new:
|
|
23
|
+
return {"type": "added", "old_lines": [], "new_lines": new.split("\n")}
|
|
24
|
+
if old and not new:
|
|
25
|
+
return {"type": "removed", "old_lines": old.split("\n"), "new_lines": []}
|
|
26
|
+
|
|
27
|
+
old_lines = old.split("\n")
|
|
28
|
+
new_lines = new.split("\n")
|
|
29
|
+
sm = difflib.SequenceMatcher(a=old_lines, b=new_lines, autojunk=False)
|
|
30
|
+
old_out: list[str] = []
|
|
31
|
+
new_out: list[str] = []
|
|
32
|
+
for tag, i1, i2, j1, j2 in sm.get_opcodes():
|
|
33
|
+
if tag == "equal":
|
|
34
|
+
continue
|
|
35
|
+
old_out.extend(old_lines[i1:i2])
|
|
36
|
+
new_out.extend(new_lines[j1:j2])
|
|
37
|
+
return {"type": "modified", "old_lines": old_out, "new_lines": new_out}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _diff_with_lines(old: str | None, new: str | None) -> dict[str, Any]:
|
|
41
|
+
"""对比单个 value,返回 {type, old_lines, new_lines, old_line_nums, new_line_nums}。
|
|
42
|
+
|
|
43
|
+
与 diff_values 同样的 type/old_lines/new_lines,额外给出变化的行号
|
|
44
|
+
(1-based,基于各自原文行列表),供 xml 父元素定位使用。
|
|
45
|
+
"""
|
|
46
|
+
old = "" if old is None else str(old)
|
|
47
|
+
new = "" if new is None else str(new)
|
|
48
|
+
if old == new:
|
|
49
|
+
return {
|
|
50
|
+
"type": "unchanged",
|
|
51
|
+
"old_lines": [],
|
|
52
|
+
"new_lines": [],
|
|
53
|
+
"old_line_nums": [],
|
|
54
|
+
"new_line_nums": [],
|
|
55
|
+
}
|
|
56
|
+
if not old and new:
|
|
57
|
+
nl = new.split("\n")
|
|
58
|
+
return {
|
|
59
|
+
"type": "added",
|
|
60
|
+
"old_lines": [],
|
|
61
|
+
"new_lines": nl,
|
|
62
|
+
"old_line_nums": [],
|
|
63
|
+
"new_line_nums": list(range(1, len(nl) + 1)),
|
|
64
|
+
}
|
|
65
|
+
if old and not new:
|
|
66
|
+
ol = old.split("\n")
|
|
67
|
+
return {
|
|
68
|
+
"type": "removed",
|
|
69
|
+
"old_lines": ol,
|
|
70
|
+
"new_lines": [],
|
|
71
|
+
"old_line_nums": list(range(1, len(ol) + 1)),
|
|
72
|
+
"new_line_nums": [],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
old_lines = old.split("\n")
|
|
76
|
+
new_lines = new.split("\n")
|
|
77
|
+
sm = difflib.SequenceMatcher(a=old_lines, b=new_lines, autojunk=False)
|
|
78
|
+
old_out: list[str] = []
|
|
79
|
+
new_out: list[str] = []
|
|
80
|
+
old_nums: list[int] = []
|
|
81
|
+
new_nums: list[int] = []
|
|
82
|
+
for tag, i1, i2, j1, j2 in sm.get_opcodes():
|
|
83
|
+
if tag == "equal":
|
|
84
|
+
continue
|
|
85
|
+
old_out.extend(old_lines[i1:i2])
|
|
86
|
+
new_out.extend(new_lines[j1:j2])
|
|
87
|
+
old_nums.extend(range(i1 + 1, i2 + 1)) # 1-based
|
|
88
|
+
new_nums.extend(range(j1 + 1, j2 + 1))
|
|
89
|
+
return {
|
|
90
|
+
"type": "modified",
|
|
91
|
+
"old_lines": old_out,
|
|
92
|
+
"new_lines": new_out,
|
|
93
|
+
"old_line_nums": old_nums,
|
|
94
|
+
"new_line_nums": new_nums,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _commit_item_kv(item: dict | None) -> tuple[str | None, str | None]:
|
|
99
|
+
"""从 commit 的 change item 取 (key, value)。
|
|
100
|
+
|
|
101
|
+
兼容嵌套 {oldItem/newItem: {key, value}} 与扁平 {key, value} / {key, oldValue/newValue}。
|
|
102
|
+
调用方决定传入 oldItem 还是 newItem。
|
|
103
|
+
"""
|
|
104
|
+
if not isinstance(item, dict):
|
|
105
|
+
return None, None
|
|
106
|
+
key = item.get("key")
|
|
107
|
+
value = item.get("value")
|
|
108
|
+
if value is None:
|
|
109
|
+
value = item.get("newValue", item.get("oldValue"))
|
|
110
|
+
return key, value
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_xml_value(v: object) -> bool:
|
|
114
|
+
"""粗判 value 是否为 xml 文本(以 '<' 开头)。避免对普通值调 expat。"""
|
|
115
|
+
return (v or "").lstrip().startswith("<")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _parent_paths(
|
|
119
|
+
old_val: object,
|
|
120
|
+
new_val: object,
|
|
121
|
+
old_line_nums: list[int],
|
|
122
|
+
new_line_nums: list[int],
|
|
123
|
+
) -> list[str]:
|
|
124
|
+
"""根据变化的行号,定位 xml 改动节点的父元素路径(去重)。
|
|
125
|
+
|
|
126
|
+
new 侧改动行(增/改)在新版本 xml 树里定位;old 侧改动行(删/改)在旧版本
|
|
127
|
+
树里定位。非 xml 值(_is_xml_value 为假)不参与,返回空。
|
|
128
|
+
一个 change 的多处改动可能落在不同父元素下,故返回列表。
|
|
129
|
+
"""
|
|
130
|
+
out: list[str] = []
|
|
131
|
+
seen: set[str] = set()
|
|
132
|
+
if new_line_nums and _is_xml_value(new_val):
|
|
133
|
+
root = parse_xml_tree(str(new_val))
|
|
134
|
+
for p in parent_paths_for_lines(root, new_line_nums):
|
|
135
|
+
if p not in seen:
|
|
136
|
+
seen.add(p)
|
|
137
|
+
out.append(p)
|
|
138
|
+
if old_line_nums and _is_xml_value(old_val):
|
|
139
|
+
root = parse_xml_tree(str(old_val))
|
|
140
|
+
for p in parent_paths_for_lines(root, old_line_nums):
|
|
141
|
+
if p not in seen:
|
|
142
|
+
seen.add(p)
|
|
143
|
+
out.append(p)
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def diff_commit(change_sets: Any) -> list[dict]:
|
|
148
|
+
"""解析 commit 的 changeSets,返回 changes 列表。
|
|
149
|
+
|
|
150
|
+
changeSets 可能是 JSON 字符串(xml/文件型 namespace 实测为此形式)
|
|
151
|
+
或 dict。每个 change: {key, type:added|removed|modified, old_lines, new_lines, parent_paths};
|
|
152
|
+
parent_paths 为 xml 文件型改动行的父元素路径列表(非 xml 为空)。
|
|
153
|
+
"""
|
|
154
|
+
if isinstance(change_sets, str):
|
|
155
|
+
try:
|
|
156
|
+
change_sets = json.loads(change_sets)
|
|
157
|
+
except (ValueError, TypeError):
|
|
158
|
+
return []
|
|
159
|
+
if not isinstance(change_sets, dict):
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
changes: list[dict] = []
|
|
163
|
+
for it in change_sets.get("createItems") or []:
|
|
164
|
+
inner = it.get("newItem") if isinstance(it, dict) else None
|
|
165
|
+
key, new_val = _commit_item_kv(inner or it)
|
|
166
|
+
d = _diff_with_lines(None, new_val)
|
|
167
|
+
changes.append(
|
|
168
|
+
{
|
|
169
|
+
"key": key,
|
|
170
|
+
"type": "added",
|
|
171
|
+
"old_lines": d["old_lines"],
|
|
172
|
+
"new_lines": d["new_lines"],
|
|
173
|
+
"parent_paths": _parent_paths(
|
|
174
|
+
None, new_val, d["old_line_nums"], d["new_line_nums"]
|
|
175
|
+
),
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
for it in change_sets.get("updateItems") or []:
|
|
179
|
+
if not isinstance(it, dict):
|
|
180
|
+
continue
|
|
181
|
+
old_inner = it.get("oldItem")
|
|
182
|
+
new_inner = it.get("newItem")
|
|
183
|
+
if isinstance(old_inner, dict) or isinstance(new_inner, dict):
|
|
184
|
+
# 嵌套:{oldItem:{key,value}, newItem:{key,value}}
|
|
185
|
+
k_old, old_val = _commit_item_kv(old_inner)
|
|
186
|
+
k_new, new_val = _commit_item_kv(new_inner)
|
|
187
|
+
key = k_new or k_old
|
|
188
|
+
else:
|
|
189
|
+
# 扁平:{key, oldValue, newValue}(须分别取,不能共用 _commit_item_kv)
|
|
190
|
+
key = it.get("key")
|
|
191
|
+
old_val = it.get("oldValue")
|
|
192
|
+
new_val = it.get("newValue", it.get("value"))
|
|
193
|
+
d = _diff_with_lines(old_val, new_val)
|
|
194
|
+
changes.append(
|
|
195
|
+
{
|
|
196
|
+
"key": key,
|
|
197
|
+
"type": "modified",
|
|
198
|
+
"old_lines": d["old_lines"],
|
|
199
|
+
"new_lines": d["new_lines"],
|
|
200
|
+
"parent_paths": _parent_paths(
|
|
201
|
+
old_val, new_val, d["old_line_nums"], d["new_line_nums"]
|
|
202
|
+
),
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
for it in change_sets.get("deleteItems") or []:
|
|
206
|
+
inner = it.get("oldItem") if isinstance(it, dict) else None
|
|
207
|
+
key, old_val = _commit_item_kv(inner or it)
|
|
208
|
+
d = _diff_with_lines(old_val, None)
|
|
209
|
+
changes.append(
|
|
210
|
+
{
|
|
211
|
+
"key": key,
|
|
212
|
+
"type": "removed",
|
|
213
|
+
"old_lines": d["old_lines"],
|
|
214
|
+
"new_lines": d["new_lines"],
|
|
215
|
+
"parent_paths": _parent_paths(
|
|
216
|
+
old_val, None, d["old_line_nums"], d["new_line_nums"]
|
|
217
|
+
),
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
return changes
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""MCP server for exploring Apollo config center (cookie auth)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from mcp.server.fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from mcp_apollo_explorer.apollo_client import (
|
|
10
|
+
DEFAULT_CLUSTER,
|
|
11
|
+
DEFAULT_ENV,
|
|
12
|
+
ApolloClient,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from mcp_apollo_explorer.diff import diff_commit
|
|
16
|
+
|
|
17
|
+
mcp = FastMCP("mcp-apollo-explorer")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _client() -> ApolloClient:
|
|
21
|
+
"""从环境变量构造 client(cookie / portal_url)。"""
|
|
22
|
+
return ApolloClient()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ns_name(ns: dict) -> str:
|
|
26
|
+
return (
|
|
27
|
+
(ns.get("baseInfo") or {}).get("namespaceName")
|
|
28
|
+
or ns.get("namespaceName")
|
|
29
|
+
or ns.get("name")
|
|
30
|
+
or ""
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _time_ge(value: str, bound: str) -> bool:
|
|
35
|
+
"""value 是否 >= bound(前缀截断比较)。
|
|
36
|
+
|
|
37
|
+
Apollo 时间形如 '2026-07-23T10:00:00.000+0800';bound 可传日期
|
|
38
|
+
'2026-07-23' 或完整时间戳。按 bound 精度截断 value 再比较,使
|
|
39
|
+
'当天' 边界正确:since='2026-07-23' 应包含当天的所有时刻。
|
|
40
|
+
"""
|
|
41
|
+
return value[: len(bound)] >= bound
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _time_le(value: str, bound: str) -> bool:
|
|
45
|
+
"""value 是否 <= bound(前缀截断比较)。
|
|
46
|
+
|
|
47
|
+
until='2026-07-23' 应包含当天所有时刻,故截断到日期精度比较;
|
|
48
|
+
否则 '2026-07-23T10:00...' 字典序大于 '2026-07-23' 会被误排除。
|
|
49
|
+
"""
|
|
50
|
+
return value[: len(bound)] <= bound
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@mcp.tool()
|
|
54
|
+
def apollo_list_namespaces(
|
|
55
|
+
app_id: str,
|
|
56
|
+
env: str = DEFAULT_ENV,
|
|
57
|
+
cluster: str = DEFAULT_CLUSTER,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""根据 appId 查询有多少个 namespace 及其名称列表。
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
app_id: Apollo appId
|
|
63
|
+
env: 环境,默认 DEV
|
|
64
|
+
cluster: 集群,默认 default
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
{app_id, env, cluster, count, namespaces:[name,...]}
|
|
68
|
+
"""
|
|
69
|
+
client = _client()
|
|
70
|
+
ns_list = client.list_namespaces(app_id, env, cluster)
|
|
71
|
+
names = [_ns_name(n) for n in ns_list]
|
|
72
|
+
return {
|
|
73
|
+
"app_id": app_id,
|
|
74
|
+
"env": env,
|
|
75
|
+
"cluster": cluster,
|
|
76
|
+
"count": len(names),
|
|
77
|
+
"namespaces": names,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@mcp.tool()
|
|
82
|
+
def apollo_get_namespace_content(
|
|
83
|
+
app_id: str,
|
|
84
|
+
namespace: str,
|
|
85
|
+
env: str = DEFAULT_ENV,
|
|
86
|
+
cluster: str = DEFAULT_CLUSTER,
|
|
87
|
+
) -> dict[str, Any]:
|
|
88
|
+
"""根据 appId 和 namespace 查询当前配置内容(items)。
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
app_id: Apollo appId
|
|
92
|
+
namespace: namespace 名称
|
|
93
|
+
env: 环境,默认 DEV
|
|
94
|
+
cluster: 集群,默认 default
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
{app_id, namespace, env, cluster, format, items:[{key,value}]}
|
|
98
|
+
"""
|
|
99
|
+
client = _client()
|
|
100
|
+
return client.get_namespace_content(app_id, namespace, env, cluster)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@mcp.tool()
|
|
104
|
+
def apollo_change_history(
|
|
105
|
+
app_ids: list[str],
|
|
106
|
+
since: str,
|
|
107
|
+
env: str = DEFAULT_ENV,
|
|
108
|
+
cluster: str = DEFAULT_CLUSTER,
|
|
109
|
+
user: str | None = None,
|
|
110
|
+
until: str | None = None,
|
|
111
|
+
max_commits_per_namespace: int = 500,
|
|
112
|
+
) -> dict[str, Any]:
|
|
113
|
+
"""查询 Apollo 变更历史:按 appId 列表查指定时间区间的提交,含每次提交的行级 diff。
|
|
114
|
+
|
|
115
|
+
基于 commit(提交)而非 release(发布),user 筛的是"改动人"而非"发布人"——
|
|
116
|
+
即使我改的内容被别人发布,也能按我的 userId 查到。
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
app_ids: 要查的 appId 列表
|
|
120
|
+
since: 起始日期/时间 ISO 格式,如 2026-06-07 或 2026-06-07T00:00:00(含)
|
|
121
|
+
env: 环境,默认 DEV
|
|
122
|
+
cluster: 集群,默认 default
|
|
123
|
+
user: 可选,按改动人 userId 过滤;不传则返回所有人
|
|
124
|
+
until: 可选,截止日期/时间 ISO 格式(含该时刻),如 2026-07-23 或
|
|
125
|
+
2026-07-23T18:00:00;不传则上不封顶;应 >= since,否则结果为空
|
|
126
|
+
max_commits_per_namespace: 每个 namespace 最多拉取的 commit 数,默认 500
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
每个匹配提交含 time/user/changes,changes 为
|
|
130
|
+
{key, type:added|removed|modified, old_lines, new_lines, parent_paths} 列表;
|
|
131
|
+
parent_paths 为 xml 文件型改动行所属元素的父路径(如 /root/config/db),非 xml 为空列表。
|
|
132
|
+
"""
|
|
133
|
+
client = _client()
|
|
134
|
+
result: dict[str, Any] = {
|
|
135
|
+
"env": env,
|
|
136
|
+
"cluster": cluster,
|
|
137
|
+
"since": since,
|
|
138
|
+
"until": until,
|
|
139
|
+
"user": user,
|
|
140
|
+
"apps": [],
|
|
141
|
+
}
|
|
142
|
+
for app_id in app_ids:
|
|
143
|
+
app_entry: dict[str, Any] = {"app_id": app_id, "namespaces": []}
|
|
144
|
+
try:
|
|
145
|
+
ns_list = client.list_namespaces(app_id, env, cluster)
|
|
146
|
+
except Exception as exc: # noqa: BLE001
|
|
147
|
+
app_entry["error"] = str(exc)
|
|
148
|
+
result["apps"].append(app_entry)
|
|
149
|
+
continue
|
|
150
|
+
for ns in ns_list:
|
|
151
|
+
ns_name = _ns_name(ns)
|
|
152
|
+
try:
|
|
153
|
+
commits = client.fetch_commits(
|
|
154
|
+
app_id,
|
|
155
|
+
ns_name,
|
|
156
|
+
env,
|
|
157
|
+
cluster,
|
|
158
|
+
since=since,
|
|
159
|
+
max_commits=max_commits_per_namespace,
|
|
160
|
+
)
|
|
161
|
+
except Exception as exc: # noqa: BLE001
|
|
162
|
+
app_entry["namespaces"].append(
|
|
163
|
+
{"namespace": ns_name, "error": str(exc)}
|
|
164
|
+
)
|
|
165
|
+
continue
|
|
166
|
+
truncated = len(commits) >= max_commits_per_namespace
|
|
167
|
+
matched: list[dict[str, Any]] = []
|
|
168
|
+
for c in commits:
|
|
169
|
+
who = c.get("dataChangeCreatedBy")
|
|
170
|
+
when = c.get("dataChangeCreatedTime") or ""
|
|
171
|
+
if not _time_ge(when, since):
|
|
172
|
+
continue
|
|
173
|
+
if until and not _time_le(when, until):
|
|
174
|
+
continue
|
|
175
|
+
if user and who != user:
|
|
176
|
+
continue
|
|
177
|
+
changes = diff_commit(c.get("changeSets"))
|
|
178
|
+
matched.append(
|
|
179
|
+
{
|
|
180
|
+
"time": when,
|
|
181
|
+
"user": who,
|
|
182
|
+
"changes": changes,
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
if matched:
|
|
186
|
+
app_entry["namespaces"].append(
|
|
187
|
+
{
|
|
188
|
+
"namespace": ns_name,
|
|
189
|
+
"truncated": truncated,
|
|
190
|
+
"matched_commits": matched,
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
result["apps"].append(app_entry)
|
|
194
|
+
return result
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def main() -> None:
|
|
198
|
+
mcp.run(transport="stdio")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
main()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""xml 文件型 namespace 改动行的父元素定位。
|
|
2
|
+
|
|
3
|
+
Apollo 的 xml/json/yaml 等"文件型" namespace 把整个文件文本作为单个 item
|
|
4
|
+
(key 固定 'content'),diff 出来的 old_lines/new_lines 是文件文本行的片段。
|
|
5
|
+
这里用 expat 解析 xml,为每个元素记录起止行号(1-based),构建带行号的元素树;
|
|
6
|
+
给定 diff 改动行号,定位包含该行的最深层元素(即"改动节点"),返回其父元素
|
|
7
|
+
的标签路径(如 /RecruitV6AIInterviewConfig/Styles)。
|
|
8
|
+
|
|
9
|
+
只用标准库 expat,不引入 lxml。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import xml.parsers.expat
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Node:
|
|
18
|
+
__slots__ = ("tag", "start", "end", "parent", "children")
|
|
19
|
+
|
|
20
|
+
def __init__(self, tag: str, start: int, parent: "_Node | None") -> None:
|
|
21
|
+
self.tag = tag
|
|
22
|
+
self.start = start # '<tag' 所在行(1-based)
|
|
23
|
+
self.end = -1 # '</tag>' 所在行;自闭合 start==end;未闭合 -1
|
|
24
|
+
self.parent = parent
|
|
25
|
+
self.children: list[_Node] = []
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def depth(self) -> int:
|
|
29
|
+
d = 0
|
|
30
|
+
p = self.parent
|
|
31
|
+
while p is not None:
|
|
32
|
+
d += 1
|
|
33
|
+
p = p.parent
|
|
34
|
+
return d
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_xml_tree(text: str) -> _Node | None:
|
|
38
|
+
"""解析 xml 文本为带行号的元素树,返回根节点;解析失败返回 None。
|
|
39
|
+
|
|
40
|
+
起止行号取自 expat 的 CurrentLineNumber:start 在 StartElementHandler,
|
|
41
|
+
end 在 EndElementHandler(自闭合标签同行)。
|
|
42
|
+
"""
|
|
43
|
+
parser = xml.parsers.expat.ParserCreate()
|
|
44
|
+
stack: list[_Node] = []
|
|
45
|
+
root: list[_Node | None] = [None]
|
|
46
|
+
|
|
47
|
+
def on_start(tag: str, _attrs) -> None:
|
|
48
|
+
parent = stack[-1] if stack else None
|
|
49
|
+
node = _Node(tag, parser.CurrentLineNumber, parent)
|
|
50
|
+
if parent is not None:
|
|
51
|
+
parent.children.append(node)
|
|
52
|
+
else:
|
|
53
|
+
root[0] = node
|
|
54
|
+
stack.append(node)
|
|
55
|
+
|
|
56
|
+
def on_end(_tag: str) -> None:
|
|
57
|
+
if stack:
|
|
58
|
+
stack.pop().end = parser.CurrentLineNumber
|
|
59
|
+
|
|
60
|
+
parser.StartElementHandler = on_start
|
|
61
|
+
parser.EndElementHandler = on_end
|
|
62
|
+
try:
|
|
63
|
+
parser.Parse(text or "", True)
|
|
64
|
+
except xml.parsers.expat.ExpatError:
|
|
65
|
+
return None
|
|
66
|
+
return root[0]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _path(node: _Node) -> str:
|
|
70
|
+
parts: list[str] = []
|
|
71
|
+
p: _Node | None = node
|
|
72
|
+
while p is not None:
|
|
73
|
+
parts.append(p.tag)
|
|
74
|
+
p = p.parent
|
|
75
|
+
parts.reverse()
|
|
76
|
+
return "/" + "/".join(parts)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _deepest_containing(root: _Node, line: int) -> _Node | None:
|
|
80
|
+
"""找 start <= line <= end 的最深层节点(改动行所属元素);无则 None。"""
|
|
81
|
+
best: _Node | None = None
|
|
82
|
+
stack = [root]
|
|
83
|
+
while stack:
|
|
84
|
+
n = stack.pop()
|
|
85
|
+
end = n.end if n.end >= 0 else n.start
|
|
86
|
+
if n.start <= line <= end:
|
|
87
|
+
if best is None or n.depth > best.depth:
|
|
88
|
+
best = n
|
|
89
|
+
stack.extend(n.children)
|
|
90
|
+
return best
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parent_path_for_line(root: _Node, line: int) -> str | None:
|
|
94
|
+
"""改动行号 line 所属最深层元素(改动节点)的父元素路径。
|
|
95
|
+
|
|
96
|
+
根元素自身无父、或行号不在任何元素范围内,返回 None。
|
|
97
|
+
"""
|
|
98
|
+
if root is None:
|
|
99
|
+
return None
|
|
100
|
+
node = _deepest_containing(root, line)
|
|
101
|
+
if node is None or node.parent is None:
|
|
102
|
+
return None
|
|
103
|
+
return _path(node.parent)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parent_paths_for_lines(root: _Node | None, lines: list[int]) -> list[str]:
|
|
107
|
+
"""批量返回多个改动行号的父元素路径(去重,保持首次出现顺序)。"""
|
|
108
|
+
out: list[str] = []
|
|
109
|
+
if root is None:
|
|
110
|
+
return out
|
|
111
|
+
seen: set[str] = set()
|
|
112
|
+
for ln in lines:
|
|
113
|
+
p = parent_path_for_line(root, ln)
|
|
114
|
+
if p and p not in seen:
|
|
115
|
+
seen.add(p)
|
|
116
|
+
out.append(p)
|
|
117
|
+
return out
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
mcp_apollo_explorer/__init__.py,sha256=uOHvXk7xPoIE6qqQQHgajsR1S_oDZG1HMF9Is3kdSSY,92
|
|
2
|
+
mcp_apollo_explorer/apollo_client.py,sha256=sHM2ZJ8lqimO6xFvkeTQ1smUotqzFHL4lnzo9Vt-8mo,5916
|
|
3
|
+
mcp_apollo_explorer/diff.py,sha256=whv_yNqYA8Pk3sCNWe6JKKtsoWjPXwdhSDBqH46sF70,7842
|
|
4
|
+
mcp_apollo_explorer/server.py,sha256=9kL7tbAoTSG1NtACQDPg1BJUW2ceLOFu_EnR8VGNrvY,6351
|
|
5
|
+
mcp_apollo_explorer/xml_parent.py,sha256=ETcxb6zytVu88-RewInabgJlvlNftsXAxpfJzMsCFkw,3678
|
|
6
|
+
mcp_apollo_explorer-0.1.0.dist-info/METADATA,sha256=kkCbYW_6lWx3p_iG2tEQM8P6AnR7o2fePm4v4QC9jkg,246
|
|
7
|
+
mcp_apollo_explorer-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
mcp_apollo_explorer-0.1.0.dist-info/entry_points.txt,sha256=qb0mGiPQ7cxrYZvrVUjE8Mc7jLk1OCnxxg0dt2sgAV0,72
|
|
9
|
+
mcp_apollo_explorer-0.1.0.dist-info/RECORD,,
|