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.
@@ -0,0 +1,3 @@
1
+ """mcp-apollo-explorer: Apollo config center explorer MCP server."""
2
+
3
+ __version__ = "0.1.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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-apollo-explorer
3
+ Version: 0.1.0
4
+ Summary: MCP server for exploring Apollo config center (history/namespace/content) via cookie auth
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.27.0
7
+ Requires-Dist: mcp[cli]>=1.2.0
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-apollo-explorer = mcp_apollo_explorer.server:main