confluence-knowledge 0.3.1__tar.gz
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.
- confluence_knowledge-0.3.1/PKG-INFO +77 -0
- confluence_knowledge-0.3.1/README.md +49 -0
- confluence_knowledge-0.3.1/pyproject.toml +56 -0
- confluence_knowledge-0.3.1/setup.cfg +4 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge/__init__.py +3 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge/auth.py +83 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge/cli.py +180 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge/client.py +152 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge/crawl.py +236 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge/extract.py +118 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge/index.py +193 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge/models.lock.json +15 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge/registration.py +134 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge/retrieval.py +207 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge/server.py +341 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge/settings.py +156 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge.egg-info/PKG-INFO +77 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge.egg-info/SOURCES.txt +30 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge.egg-info/dependency_links.txt +1 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge.egg-info/entry_points.txt +2 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge.egg-info/requires.txt +15 -0
- confluence_knowledge-0.3.1/src/confluence_knowledge.egg-info/top_level.txt +1 -0
- confluence_knowledge-0.3.1/tests/test_agent_doc_consistency.py +34 -0
- confluence_knowledge-0.3.1/tests/test_client_get_only.py +105 -0
- confluence_knowledge-0.3.1/tests/test_crawl.py +133 -0
- confluence_knowledge-0.3.1/tests/test_extract.py +65 -0
- confluence_knowledge-0.3.1/tests/test_index.py +54 -0
- confluence_knowledge-0.3.1/tests/test_prepare_models.py +122 -0
- confluence_knowledge-0.3.1/tests/test_registration.py +69 -0
- confluence_knowledge-0.3.1/tests/test_retrieval.py +71 -0
- confluence_knowledge-0.3.1/tests/test_secrets.py +31 -0
- confluence_knowledge-0.3.1/tests/test_server_flow.py +175 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: confluence-knowledge
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Local, GET-only Confluence subtree indexer + grounded-Q&A MCP service
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/nkunal1992/agents
|
|
7
|
+
Project-URL: Documentation, https://github.com/nkunal1992/agents/blob/main/docs/confluence-knowledge.md
|
|
8
|
+
Project-URL: Issues, https://github.com/nkunal1992/agents/issues
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: httpx<0.29,>=0.27
|
|
16
|
+
Requires-Dist: beautifulsoup4>=4.12
|
|
17
|
+
Requires-Dist: numpy>=1.26
|
|
18
|
+
Requires-Dist: keyring>=25.2
|
|
19
|
+
Requires-Dist: mcp<2,>=1.6
|
|
20
|
+
Requires-Dist: pip-system-certs>=5.0
|
|
21
|
+
Provides-Extra: models
|
|
22
|
+
Requires-Dist: sentence-transformers<4.0,>=3.0; extra == "models"
|
|
23
|
+
Requires-Dist: huggingface-hub>=0.26; extra == "models"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# confluence-knowledge (local MCP service)
|
|
30
|
+
|
|
31
|
+
Local companion service for the `confluence-knowledge` Copilot agent.
|
|
32
|
+
See `../../docs/confluence-knowledge.md` for the full guide.
|
|
33
|
+
|
|
34
|
+
- GET-only `ConfluenceClient` — non-GET raises `SecurityException` pre-flight.
|
|
35
|
+
- Recursive subtree crawl (Cloud v2 + Data Center), resumable.
|
|
36
|
+
- Local embeddings (`all-MiniLM-L6-v2`) + local reranker
|
|
37
|
+
(`ms-marco-MiniLM-L6-v2`) — Apache-2.0, pinned in `models.lock.json`,
|
|
38
|
+
never a cloud embedding call.
|
|
39
|
+
- SQLite + FTS5 + vector-blob index under `.conf_knowledge_index/`.
|
|
40
|
+
- Credentials via OS keyring / env var / chmod-600 `.conf_auth.json`.
|
|
41
|
+
|
|
42
|
+
## Dev
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python3.11+ -m venv .venv && .venv/bin/pip install -e '.[dev]'
|
|
46
|
+
.venv/bin/python -m pytest # all offline — no network needed
|
|
47
|
+
.venv/bin/python -m ruff check src tests
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Before every release, run the end-to-end smoke test. It spawns the real stdio
|
|
51
|
+
server through the MCP client SDK and uses the real models against a fake
|
|
52
|
+
Confluence on localhost, with simulated network latency:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
.venv/bin/pip install -e '.[models]'
|
|
56
|
+
CONF_KNOWLEDGE_HOME=/tmp/ck/home .venv/bin/confluence-knowledge prepare-models
|
|
57
|
+
CONF_KNOWLEDGE_HOME=/tmp/ck/home .venv/bin/python scripts/e2e_smoke.py # ends with "E2E OK"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`e2e_bootstrap.py` goes further — it replays the agent's entire install flow
|
|
61
|
+
in a sandboxed $HOME (python discovery → venv → `pip install` from the
|
|
62
|
+
shipped `dist/` bundle → models → credentials → `register` → the real server
|
|
63
|
+
over stdio → a full Q&A round-trip). Run it before every release:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
CK_MODEL_CACHE=/tmp/ck/home/models .venv/bin/python scripts/e2e_bootstrap.py # "E2E BOOTSTRAP OK"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## CLI
|
|
70
|
+
|
|
71
|
+
| Command | Purpose |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `confluence-knowledge setup` | guided env/site/token setup (hidden input, keyring) |
|
|
74
|
+
| `confluence-knowledge prepare-models` | fetch pinned local models once |
|
|
75
|
+
| `confluence-knowledge doctor` | verify config + heartbeat + models |
|
|
76
|
+
| `confluence-knowledge serve` | run the stdio MCP server (what the IDE launches) |
|
|
77
|
+
| `confluence-knowledge clean` | delete the local index (`~/.confluence-knowledge/work` unless `CONF_KNOWLEDGE_WORKDIR` is set) |
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# confluence-knowledge (local MCP service)
|
|
2
|
+
|
|
3
|
+
Local companion service for the `confluence-knowledge` Copilot agent.
|
|
4
|
+
See `../../docs/confluence-knowledge.md` for the full guide.
|
|
5
|
+
|
|
6
|
+
- GET-only `ConfluenceClient` — non-GET raises `SecurityException` pre-flight.
|
|
7
|
+
- Recursive subtree crawl (Cloud v2 + Data Center), resumable.
|
|
8
|
+
- Local embeddings (`all-MiniLM-L6-v2`) + local reranker
|
|
9
|
+
(`ms-marco-MiniLM-L6-v2`) — Apache-2.0, pinned in `models.lock.json`,
|
|
10
|
+
never a cloud embedding call.
|
|
11
|
+
- SQLite + FTS5 + vector-blob index under `.conf_knowledge_index/`.
|
|
12
|
+
- Credentials via OS keyring / env var / chmod-600 `.conf_auth.json`.
|
|
13
|
+
|
|
14
|
+
## Dev
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
python3.11+ -m venv .venv && .venv/bin/pip install -e '.[dev]'
|
|
18
|
+
.venv/bin/python -m pytest # all offline — no network needed
|
|
19
|
+
.venv/bin/python -m ruff check src tests
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Before every release, run the end-to-end smoke test. It spawns the real stdio
|
|
23
|
+
server through the MCP client SDK and uses the real models against a fake
|
|
24
|
+
Confluence on localhost, with simulated network latency:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
.venv/bin/pip install -e '.[models]'
|
|
28
|
+
CONF_KNOWLEDGE_HOME=/tmp/ck/home .venv/bin/confluence-knowledge prepare-models
|
|
29
|
+
CONF_KNOWLEDGE_HOME=/tmp/ck/home .venv/bin/python scripts/e2e_smoke.py # ends with "E2E OK"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`e2e_bootstrap.py` goes further — it replays the agent's entire install flow
|
|
33
|
+
in a sandboxed $HOME (python discovery → venv → `pip install` from the
|
|
34
|
+
shipped `dist/` bundle → models → credentials → `register` → the real server
|
|
35
|
+
over stdio → a full Q&A round-trip). Run it before every release:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
CK_MODEL_CACHE=/tmp/ck/home/models .venv/bin/python scripts/e2e_bootstrap.py # "E2E BOOTSTRAP OK"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## CLI
|
|
42
|
+
|
|
43
|
+
| Command | Purpose |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `confluence-knowledge setup` | guided env/site/token setup (hidden input, keyring) |
|
|
46
|
+
| `confluence-knowledge prepare-models` | fetch pinned local models once |
|
|
47
|
+
| `confluence-knowledge doctor` | verify config + heartbeat + models |
|
|
48
|
+
| `confluence-knowledge serve` | run the stdio MCP server (what the IDE launches) |
|
|
49
|
+
| `confluence-knowledge clean` | delete the local index (`~/.confluence-knowledge/work` unless `CONF_KNOWLEDGE_WORKDIR` is set) |
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "confluence-knowledge"
|
|
7
|
+
version = "0.3.1"
|
|
8
|
+
description = "Local, GET-only Confluence subtree indexer + grounded-Q&A MCP service"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"httpx>=0.27,<0.29",
|
|
20
|
+
"beautifulsoup4>=4.12",
|
|
21
|
+
"numpy>=1.26",
|
|
22
|
+
"keyring>=25.2",
|
|
23
|
+
"mcp>=1.6,<2",
|
|
24
|
+
"pip-system-certs>=5.0", # OS cert store → works behind TLS-inspecting proxies
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Repository = "https://github.com/nkunal1992/agents"
|
|
29
|
+
Documentation = "https://github.com/nkunal1992/agents/blob/main/docs/confluence-knowledge.md"
|
|
30
|
+
Issues = "https://github.com/nkunal1992/agents/issues"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
models = [
|
|
34
|
+
"sentence-transformers>=3.0,<4.0",
|
|
35
|
+
"huggingface-hub>=0.26",
|
|
36
|
+
]
|
|
37
|
+
dev = [
|
|
38
|
+
"pytest>=8.0",
|
|
39
|
+
"pytest-cov>=5.0",
|
|
40
|
+
"ruff>=0.6",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
confluence-knowledge = "confluence_knowledge.cli:main"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.packages.find]
|
|
47
|
+
where = ["src"]
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.package-data]
|
|
50
|
+
confluence_knowledge = ["models.lock.json"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
line-length = 110
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Credential storage: OS keyring → env var → protected file (explicit choice).
|
|
2
|
+
|
|
3
|
+
Tokens are never accepted via chat, MCP tool arguments, or command-line flags —
|
|
4
|
+
the setup wizard prompts with getpass in the user's own terminal.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .settings import APP_NAME, AUTH_FILE_NAME, ensure_gitignore, reject_unsafe_path
|
|
15
|
+
|
|
16
|
+
SERVICE = APP_NAME
|
|
17
|
+
ENV_TOKEN = "CONF_KNOWLEDGE_TOKEN"
|
|
18
|
+
ENV_EMAIL = "CONF_KNOWLEDGE_EMAIL"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _keyring_store(ref: str, secret: str) -> bool:
|
|
22
|
+
try:
|
|
23
|
+
import keyring
|
|
24
|
+
from keyring.backends.fail import Keyring as FailBackend
|
|
25
|
+
if isinstance(keyring.get_keyring(), FailBackend):
|
|
26
|
+
return False
|
|
27
|
+
keyring.set_password(SERVICE, ref, secret)
|
|
28
|
+
return True
|
|
29
|
+
except Exception: # noqa: BLE001 — keyring backends fail in many ways; fallback is intentional
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _keyring_get(ref: str) -> str | None:
|
|
34
|
+
try:
|
|
35
|
+
import keyring
|
|
36
|
+
return keyring.get_password(SERVICE, ref)
|
|
37
|
+
except Exception: # noqa: BLE001 — unavailable keyring → try next provider
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def store_credential(workdir: Path, home: Path, ref: str, token: str) -> str:
|
|
42
|
+
"""Persist token: keyring preferred, protected file as explicit fallback.
|
|
43
|
+
|
|
44
|
+
Returns a human description of where it went (never the token).
|
|
45
|
+
"""
|
|
46
|
+
if _keyring_store(ref, token):
|
|
47
|
+
return "OS credential store"
|
|
48
|
+
# file fallback — gitignore BEFORE writing
|
|
49
|
+
ensure_gitignore(workdir, [AUTH_FILE_NAME, ".conf_knowledge_index/"])
|
|
50
|
+
p = reject_unsafe_path(workdir / AUTH_FILE_NAME)
|
|
51
|
+
data = json.loads(p.read_text()) if p.exists() else {}
|
|
52
|
+
data[ref] = token
|
|
53
|
+
tmp = p.with_suffix(".tmp")
|
|
54
|
+
tmp.write_text(json.dumps(data))
|
|
55
|
+
os.replace(tmp, p)
|
|
56
|
+
if os.name != "nt":
|
|
57
|
+
os.chmod(p, stat.S_IRUSR | stat.S_IWUSR)
|
|
58
|
+
return f"protected file {p} (chmod 600)"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_credential(workdir: Path, ref: str) -> str | None:
|
|
62
|
+
"""Lookup order: env var (CI/scripts) → keyring → protected file."""
|
|
63
|
+
t = os.environ.get(ENV_TOKEN)
|
|
64
|
+
if t:
|
|
65
|
+
return t
|
|
66
|
+
t = _keyring_get(ref)
|
|
67
|
+
if t:
|
|
68
|
+
return t
|
|
69
|
+
p = workdir / AUTH_FILE_NAME
|
|
70
|
+
if p.exists():
|
|
71
|
+
data = json.loads(p.read_text())
|
|
72
|
+
return data.get(ref)
|
|
73
|
+
p2 = Path.home() / ".confluence-knowledge" / AUTH_FILE_NAME
|
|
74
|
+
if p2.exists():
|
|
75
|
+
return json.loads(p2.read_text()).get(ref)
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def redact(text: str, secrets: list[str]) -> str:
|
|
80
|
+
for s in secrets:
|
|
81
|
+
if s:
|
|
82
|
+
text = text.replace(s, "***")
|
|
83
|
+
return text
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""confluence-knowledge CLI: setup / doctor / prepare-models / serve / clean.
|
|
2
|
+
|
|
3
|
+
The setup wizard prompts for tokens via getpass in the user's own terminal —
|
|
4
|
+
tokens never travel through chat or command-line arguments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import getpass
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .auth import store_credential
|
|
17
|
+
from .client import AccessError, make_client
|
|
18
|
+
from .retrieval import prepare_models
|
|
19
|
+
from .settings import (
|
|
20
|
+
AUTH_FILE_NAME,
|
|
21
|
+
INDEX_DIR_NAME,
|
|
22
|
+
SetupError,
|
|
23
|
+
SiteProfile,
|
|
24
|
+
default_home,
|
|
25
|
+
ensure_gitignore,
|
|
26
|
+
resolve_workdir,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _wizard() -> int:
|
|
31
|
+
print("confluence-knowledge setup\n")
|
|
32
|
+
env = ""
|
|
33
|
+
while env not in ("1", "2"):
|
|
34
|
+
env = input("Confluence environment — [1] Cloud (atlassian.net) "
|
|
35
|
+
"or [2] Data Center (corporate URL)? ").strip()
|
|
36
|
+
env = "cloud" if env == "1" else "datacenter"
|
|
37
|
+
|
|
38
|
+
if env == "cloud":
|
|
39
|
+
print("\nCreate a token: sign in to https://id.atlassian.com → "
|
|
40
|
+
"Security → Create and manage API tokens → Create API token "
|
|
41
|
+
"(choose Confluence read scopes if scoped tokens are offered).")
|
|
42
|
+
site = input("\nSite URL (e.g. https://acme.atlassian.net): ").strip()
|
|
43
|
+
email = input("Atlassian account email: ").strip()
|
|
44
|
+
else:
|
|
45
|
+
print("\nCreate a token: Confluence → your profile picture → Settings → "
|
|
46
|
+
"Personal Access Tokens → Create token (read access is enough).")
|
|
47
|
+
site = input("\nSite URL incl. context path (e.g. https://conf.acme.corp/confluence): ").strip()
|
|
48
|
+
email = None
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
base = SiteProfile.canonical_base(env, site)
|
|
52
|
+
except SetupError as e:
|
|
53
|
+
print(f"error: {e}", file=sys.stderr)
|
|
54
|
+
return 2
|
|
55
|
+
|
|
56
|
+
workdir = resolve_workdir()
|
|
57
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
home = default_home()
|
|
59
|
+
# gitignore protections BEFORE any secret/cache write
|
|
60
|
+
try:
|
|
61
|
+
ensure_gitignore(workdir, [AUTH_FILE_NAME, ".conf_knowledge_index/"])
|
|
62
|
+
except SetupError as e:
|
|
63
|
+
print(f"error: {e}", file=sys.stderr)
|
|
64
|
+
return 2
|
|
65
|
+
|
|
66
|
+
ref = f"{env}:{base}:{email or '-'}"
|
|
67
|
+
token = getpass.getpass("Token (input hidden): ")
|
|
68
|
+
if not token:
|
|
69
|
+
print("error: empty token", file=sys.stderr)
|
|
70
|
+
return 2
|
|
71
|
+
|
|
72
|
+
where = store_credential(workdir, home, ref, token)
|
|
73
|
+
SiteProfile(env, base, email, ref).save(home)
|
|
74
|
+
|
|
75
|
+
# heartbeat
|
|
76
|
+
try:
|
|
77
|
+
c = make_client(SiteProfile(env, base, email, ref), token)
|
|
78
|
+
ep = "wiki/rest/api/user/current" if env == "cloud" else "rest/api/user/current"
|
|
79
|
+
who = c.get(ep).json()
|
|
80
|
+
print(f"\nconnected as: {who.get('displayName') or who.get('username') or 'OK'}")
|
|
81
|
+
except Exception as e: # noqa: BLE001 — any connect failure (auth/network/TLS) exits non-zero
|
|
82
|
+
print(f"\nconnection check failed: {e}", file=sys.stderr)
|
|
83
|
+
return 3
|
|
84
|
+
|
|
85
|
+
print(f"credential stored: {where}")
|
|
86
|
+
print(f"profile: {home / 'profile.json'}")
|
|
87
|
+
print("\nNext: `confluence-knowledge prepare-models` to fetch the local "
|
|
88
|
+
"models (Apache-2.0), then point your IDE's MCP config at:\n"
|
|
89
|
+
f" command: {sys.executable}\n"
|
|
90
|
+
f" args: [\"-m\", \"confluence_knowledge.server\"]\n"
|
|
91
|
+
f" env: CONF_KNOWLEDGE_WORKDIR={workdir}")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _doctor() -> int:
|
|
96
|
+
from .server import status
|
|
97
|
+
try:
|
|
98
|
+
s = status()
|
|
99
|
+
print(json.dumps(s, indent=2))
|
|
100
|
+
if s.get("configured"):
|
|
101
|
+
from .server import _client_for # internal but fine in CLI
|
|
102
|
+
profile = SiteProfile.load(default_home())
|
|
103
|
+
try:
|
|
104
|
+
c = _client_for(profile)
|
|
105
|
+
ep = ("wiki/rest/api/user/current" if profile.env == "cloud"
|
|
106
|
+
else "rest/api/user/current")
|
|
107
|
+
who = c.get(ep).json()
|
|
108
|
+
print("heartbeat: OK —",
|
|
109
|
+
who.get("displayName") or who.get("username"))
|
|
110
|
+
except (AccessError, SetupError) as e:
|
|
111
|
+
print(f"heartbeat: FAILED — {e}", file=sys.stderr)
|
|
112
|
+
return 3
|
|
113
|
+
return 0
|
|
114
|
+
except SetupError as e:
|
|
115
|
+
print(f"setup error: {e}", file=sys.stderr)
|
|
116
|
+
return 2
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main() -> None:
|
|
120
|
+
ap = argparse.ArgumentParser(prog="confluence-knowledge")
|
|
121
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
122
|
+
sub.add_parser("setup", help="guided credential + config setup")
|
|
123
|
+
sub.add_parser("doctor", help="verify config, credentials, models")
|
|
124
|
+
sub.add_parser("prepare-models", help="download the pinned local models")
|
|
125
|
+
reg = sub.add_parser(
|
|
126
|
+
"register",
|
|
127
|
+
help="write this server into the IDE's MCP config (no manual paste)")
|
|
128
|
+
reg.add_argument("--ide", default="auto",
|
|
129
|
+
choices=["auto", "all", "vscode", "jetbrains"],
|
|
130
|
+
help="which config files to write (default: all that apply)")
|
|
131
|
+
reg.add_argument("--workspace", type=Path, default=None,
|
|
132
|
+
help="also write <workspace>/.vscode/mcp.json")
|
|
133
|
+
reg.add_argument("--remove", action="store_true",
|
|
134
|
+
help="remove our entry instead of adding it")
|
|
135
|
+
sub.add_parser("serve", help="run the stdio MCP server")
|
|
136
|
+
clean = sub.add_parser("clean", help="delete the local index for this repo")
|
|
137
|
+
clean.add_argument("--yes", action="store_true")
|
|
138
|
+
args = ap.parse_args()
|
|
139
|
+
|
|
140
|
+
if args.cmd == "setup":
|
|
141
|
+
sys.exit(_wizard())
|
|
142
|
+
if args.cmd == "doctor":
|
|
143
|
+
sys.exit(_doctor())
|
|
144
|
+
if args.cmd == "prepare-models":
|
|
145
|
+
for line in prepare_models(default_home()):
|
|
146
|
+
print(line)
|
|
147
|
+
return
|
|
148
|
+
if args.cmd == "register":
|
|
149
|
+
from .registration import register
|
|
150
|
+
try:
|
|
151
|
+
wd = (Path(os.environ["CONF_KNOWLEDGE_WORKDIR"]).expanduser().resolve()
|
|
152
|
+
if os.environ.get("CONF_KNOWLEDGE_WORKDIR") else None)
|
|
153
|
+
results = register(ide=args.ide, workspace=args.workspace,
|
|
154
|
+
workdir=wd, remove=args.remove)
|
|
155
|
+
except SetupError as e:
|
|
156
|
+
print(f"error: {e}", file=sys.stderr)
|
|
157
|
+
sys.exit(2)
|
|
158
|
+
for path, action in results:
|
|
159
|
+
print(f"{action}: {path}")
|
|
160
|
+
if not args.remove:
|
|
161
|
+
print("\nReload your IDE so Copilot picks up the server, "
|
|
162
|
+
"then re-ask the agent.")
|
|
163
|
+
return
|
|
164
|
+
if args.cmd == "serve":
|
|
165
|
+
from .server import main as serve
|
|
166
|
+
serve()
|
|
167
|
+
return
|
|
168
|
+
if args.cmd == "clean":
|
|
169
|
+
import shutil
|
|
170
|
+
|
|
171
|
+
target = resolve_workdir() / INDEX_DIR_NAME
|
|
172
|
+
if target.exists() and (args.yes or input(
|
|
173
|
+
f"delete {target}? [y/N] ").strip().lower() == "y"):
|
|
174
|
+
shutil.rmtree(target)
|
|
175
|
+
print("index deleted")
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
main()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""GET-only Confluence REST client.
|
|
2
|
+
|
|
3
|
+
Every transport call passes through request(): a non-GET method is rejected
|
|
4
|
+
with SecurityException before any network I/O, and paths are validated against
|
|
5
|
+
a per-adapter endpoint allowlist. No convenience method can bypass this.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from urllib.parse import urljoin, urlparse
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SecurityException(RuntimeError):
|
|
18
|
+
"""Raised when a write/delete/mutation HTTP operation is attempted."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AccessError(RuntimeError):
|
|
22
|
+
"""Authentication, permission, or reachability failure (actionable)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class EndpointRule:
|
|
27
|
+
pattern: re.Pattern # matched against the API path
|
|
28
|
+
purpose: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConfluenceClient:
|
|
32
|
+
"""GET-only client bound to one site base URL and one route allowlist."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
base_url: str,
|
|
37
|
+
headers: dict[str, str],
|
|
38
|
+
rules: list[EndpointRule],
|
|
39
|
+
timeout: float = 20.0,
|
|
40
|
+
max_bytes: int = 8 * 1024 * 1024,
|
|
41
|
+
session: httpx.Client | None = None,
|
|
42
|
+
):
|
|
43
|
+
u = urlparse(base_url)
|
|
44
|
+
if u.scheme != "https" and u.hostname not in ("localhost", "127.0.0.1"):
|
|
45
|
+
raise AccessError("Confluence base URL must be https")
|
|
46
|
+
self._base = base_url.rstrip("/") + "/"
|
|
47
|
+
self._base_origin = f"{u.scheme}://{u.netloc}"
|
|
48
|
+
self._base_path = u.path.rstrip("/")
|
|
49
|
+
self._rules = rules
|
|
50
|
+
self._http = session or httpx.Client(
|
|
51
|
+
headers={"Accept": "application/json", **headers},
|
|
52
|
+
timeout=timeout,
|
|
53
|
+
follow_redirects=False, # never carry credentials through redirects
|
|
54
|
+
)
|
|
55
|
+
self._max_bytes = max_bytes
|
|
56
|
+
|
|
57
|
+
# ---- the GET-only wrapper ----
|
|
58
|
+
|
|
59
|
+
def request(self, method: str, path: str, params: dict | None = None) -> httpx.Response:
|
|
60
|
+
if method.upper() != "GET":
|
|
61
|
+
raise SecurityException(
|
|
62
|
+
"Error: This agent is strictly read-only. "
|
|
63
|
+
"Write/Delete operations are forbidden."
|
|
64
|
+
)
|
|
65
|
+
api_path = self._validate_path(path)
|
|
66
|
+
resp = self._http.get(urljoin(self._base, api_path.lstrip("/")), params=params)
|
|
67
|
+
if resp.status_code >= 400:
|
|
68
|
+
raise AccessError(self._describe(resp))
|
|
69
|
+
if resp.status_code >= 300:
|
|
70
|
+
raise AccessError(
|
|
71
|
+
f"unexpected redirect ({resp.status_code}) — check the base URL/context path"
|
|
72
|
+
)
|
|
73
|
+
if len(resp.content) > self._max_bytes:
|
|
74
|
+
raise AccessError(f"response exceeds {self._max_bytes} bytes — refusing to buffer")
|
|
75
|
+
return resp
|
|
76
|
+
|
|
77
|
+
def get(self, path: str, params: dict | None = None) -> httpx.Response:
|
|
78
|
+
return self.request("GET", path, params)
|
|
79
|
+
|
|
80
|
+
# ---- validation ----
|
|
81
|
+
|
|
82
|
+
def _validate_path(self, path: str) -> str:
|
|
83
|
+
"""Normalize to an API-relative path; enforce site + endpoint bounds."""
|
|
84
|
+
u = urlparse(path)
|
|
85
|
+
if u.scheme or u.netloc:
|
|
86
|
+
# absolute URL (e.g. pagination next-link) must stay inside
|
|
87
|
+
# the configured origin + API base path
|
|
88
|
+
full_base = self._base_origin + self._base_path
|
|
89
|
+
full = f"{u.scheme}://{u.netloc}{u.path}"
|
|
90
|
+
if not (full == full_base or full.startswith(full_base + "/")):
|
|
91
|
+
raise SecurityException(
|
|
92
|
+
"Error: request escapes the configured Confluence site — refused."
|
|
93
|
+
)
|
|
94
|
+
rel = u.path[len(self._base_path):].lstrip("/")
|
|
95
|
+
elif path.startswith("/"):
|
|
96
|
+
# site-relative path must live under the API base path
|
|
97
|
+
if self._base_path and not path.startswith(self._base_path + "/"):
|
|
98
|
+
raise SecurityException(
|
|
99
|
+
"Error: request escapes the configured Confluence site — refused."
|
|
100
|
+
)
|
|
101
|
+
rel = path[len(self._base_path):].lstrip("/")
|
|
102
|
+
else:
|
|
103
|
+
rel = path
|
|
104
|
+
if ".." in rel:
|
|
105
|
+
raise SecurityException("Error: path traversal rejected.")
|
|
106
|
+
if not any(r.pattern.match(rel) for r in self._rules):
|
|
107
|
+
raise SecurityException(
|
|
108
|
+
"Error: This agent is strictly read-only. "
|
|
109
|
+
f"Endpoint not on the allowed GET list: {rel}"
|
|
110
|
+
)
|
|
111
|
+
return rel
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _describe(resp: httpx.Response) -> str:
|
|
115
|
+
s = resp.status_code
|
|
116
|
+
return {
|
|
117
|
+
401: "authentication failed (401) — check the token and account",
|
|
118
|
+
403: "permission denied (403) — your account can't view this content",
|
|
119
|
+
404: "not found (404) — check the page ID/space and base URL",
|
|
120
|
+
429: "rate limited (429) — retry after the server's Retry-After",
|
|
121
|
+
}.get(s, f"HTTP {s} from Confluence")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---- adapters ----
|
|
125
|
+
|
|
126
|
+
RULES_V2 = [
|
|
127
|
+
EndpointRule(re.compile(r"wiki/api/v2/pages/\d+/?$"), "page body"),
|
|
128
|
+
EndpointRule(re.compile(r"wiki/api/v2/pages/\d+/direct-children/?$"), "children"),
|
|
129
|
+
EndpointRule(re.compile(r"wiki/api/v2/folders/\d+/direct-children/?$"), "folder children"),
|
|
130
|
+
EndpointRule(re.compile(r"wiki/rest/api/user/current/?$"), "identity"),
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
RULES_DC = [
|
|
134
|
+
EndpointRule(re.compile(r"(?:confluence/)?rest/api/content/\d+/?$"), "page body"),
|
|
135
|
+
EndpointRule(re.compile(r"(?:confluence/)?rest/api/content/\d+/child/page/?$"), "children"),
|
|
136
|
+
EndpointRule(re.compile(r"(?:confluence/)?rest/api/user/current/?$"), "identity"),
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
def make_client(profile, token: str, cloud_id: str | None = None) -> ConfluenceClient:
|
|
140
|
+
"""Build a client for the configured profile. Token only in headers."""
|
|
141
|
+
import base64
|
|
142
|
+
|
|
143
|
+
if profile.env == "cloud":
|
|
144
|
+
auth = base64.b64encode(f"{profile.email}:{token}".encode()).decode()
|
|
145
|
+
# scoped tokens go through the Atlassian gateway bound to one cloudId;
|
|
146
|
+
# the same v2 route shapes apply relative to that base path
|
|
147
|
+
base = (f"https://api.atlassian.com/ex/confluence/{cloud_id}"
|
|
148
|
+
if cloud_id else profile.base_url)
|
|
149
|
+
return ConfluenceClient(base, {"Authorization": f"Basic {auth}"}, RULES_V2)
|
|
150
|
+
return ConfluenceClient(
|
|
151
|
+
profile.base_url, {"Authorization": f"Bearer {token}"}, RULES_DC
|
|
152
|
+
)
|