sharepoint-knowledge 0.1.0__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.
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: sharepoint-knowledge
3
+ Version: 0.1.0
4
+ Summary: SharePoint connector for the grounded-Q&A engine — site pages + document libraries, Entra device-code sign-in, local models, cited answers
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/nkunal1992/agents
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: confluence-knowledge[files]>=0.4.1
10
+ Requires-Dist: msal>=1.31
11
+ Requires-Dist: httpx>=0.27
12
+ Requires-Dist: mcp>=1.8
13
+ Provides-Extra: models
14
+ Requires-Dist: confluence-knowledge[models]>=0.4.1; extra == "models"
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8.0; extra == "dev"
17
+ Requires-Dist: ruff>=0.5; extra == "dev"
18
+ Requires-Dist: python-docx>=1.1; extra == "dev"
19
+ Requires-Dist: python-pptx>=1.0; extra == "dev"
20
+ Requires-Dist: openpyxl>=3.1; extra == "dev"
21
+ Requires-Dist: pypdf>=5.0; extra == "dev"
22
+
23
+ # sharepoint-knowledge
24
+
25
+ SharePoint connector on the shared grounded-Q&A engine — Site Pages +
26
+ document libraries (docx/pdf/pptx/xlsx/csv/txt/md/html), Entra device-code
27
+ sign-in, local models, cited answers. Same engine as
28
+ [`confluence-knowledge`](../confluence-knowledge) and
29
+ [`local-knowledge`](../local-knowledge).
30
+
31
+ - `sharepoint_knowledge/auth_sp.py` — MSAL device-code flow, token cache in
32
+ the OS keyring (protected-file fallback), silent refresh
33
+ - `sharepoint_knowledge/client_sp.py` — GET-only Graph client; downloads go
34
+ through `@microsoft.graph.downloadUrl` WITHOUT the Bearer header
35
+ - `sharepoint_knowledge/crawl_sp.py` — durable BFS site → pages + libraries
36
+ - `sharepoint_knowledge/server_sp.py` — the `sharepoint-knowledge/*` tools
37
+ - `sharepoint_knowledge/cli_sp.py` — setup/doctor/prepare-models/register/
38
+ update/serve/clean
39
+
40
+ See `docs/sharepoint-knowledge.md` for the full guide.
@@ -0,0 +1,18 @@
1
+ # sharepoint-knowledge
2
+
3
+ SharePoint connector on the shared grounded-Q&A engine — Site Pages +
4
+ document libraries (docx/pdf/pptx/xlsx/csv/txt/md/html), Entra device-code
5
+ sign-in, local models, cited answers. Same engine as
6
+ [`confluence-knowledge`](../confluence-knowledge) and
7
+ [`local-knowledge`](../local-knowledge).
8
+
9
+ - `sharepoint_knowledge/auth_sp.py` — MSAL device-code flow, token cache in
10
+ the OS keyring (protected-file fallback), silent refresh
11
+ - `sharepoint_knowledge/client_sp.py` — GET-only Graph client; downloads go
12
+ through `@microsoft.graph.downloadUrl` WITHOUT the Bearer header
13
+ - `sharepoint_knowledge/crawl_sp.py` — durable BFS site → pages + libraries
14
+ - `sharepoint_knowledge/server_sp.py` — the `sharepoint-knowledge/*` tools
15
+ - `sharepoint_knowledge/cli_sp.py` — setup/doctor/prepare-models/register/
16
+ update/serve/clean
17
+
18
+ See `docs/sharepoint-knowledge.md` for the full guide.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sharepoint-knowledge"
7
+ version = "0.1.0"
8
+ description = "SharePoint connector for the grounded-Q&A engine — site pages + document libraries, Entra device-code sign-in, local models, cited answers"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = {text = "Apache-2.0"}
12
+ dependencies = [
13
+ "confluence-knowledge[files]>=0.4.1",
14
+ "msal>=1.31",
15
+ "httpx>=0.27",
16
+ "mcp>=1.8",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ models = [
21
+ "confluence-knowledge[models]>=0.4.1",
22
+ ]
23
+ dev = [
24
+ "pytest>=8.0",
25
+ "ruff>=0.5",
26
+ "python-docx>=1.1",
27
+ "python-pptx>=1.0",
28
+ "openpyxl>=3.1",
29
+ "pypdf>=5.0",
30
+ ]
31
+
32
+ [project.scripts]
33
+ sharepoint-knowledge = "sharepoint_knowledge.cli_sp:main"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/nkunal1992/agents"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """sharepoint-knowledge — SharePoint connector on the shared engine."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,118 @@
1
+ """Entra auth for sharepoint-knowledge — device-code flow + silent refresh.
2
+
3
+ The MSAL token cache is stored as a credential blob through the shared
4
+ keyring/file machinery (same posture as the Confluence PAT). The access
5
+ token itself never enters chat, logs, or command-line arguments.
6
+
7
+ SP_KNOWLEDGE_TOKEN env var short-circuits everything (tests/CI).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+
16
+ from confluence_knowledge.auth import get_credential, store_credential
17
+ from confluence_knowledge.settings import SetupError
18
+
19
+ SCOPES = ["https://graph.microsoft.com/Sites.Read.All", "offline_access"]
20
+ ENV_TOKEN = "SP_KNOWLEDGE_TOKEN"
21
+ AUTH_FILE = ".sp_auth.json"
22
+ INDEX_DIR = ".sp_knowledge_index"
23
+ SERVICE = "sharepoint-knowledge"
24
+
25
+
26
+ def _cache_ref(profile) -> str:
27
+ return f"cache:{profile.credential_ref}"
28
+
29
+
30
+ def _load_msal():
31
+ try:
32
+ import msal
33
+ except ImportError as e:
34
+ raise SetupError(
35
+ "msal isn't installed — reinstall with `pip install "
36
+ "sharepoint-knowledge[models]`") from e
37
+ return msal
38
+
39
+
40
+ def _load_cache(workdir: Path, profile):
41
+ msal = _load_msal()
42
+ blob = get_credential(workdir, _cache_ref(profile), env=ENV_TOKEN,
43
+ service=SERVICE, auth_file=AUTH_FILE,
44
+ home_dir=".sharepoint-knowledge")
45
+ if blob:
46
+ try:
47
+ cache = msal.SerializableTokenCache()
48
+ cache.deserialize(json.loads(blob))
49
+ return cache
50
+ except Exception: # noqa: BLE001 — corrupt cache → re-sign-in
51
+ return None
52
+ return None
53
+
54
+
55
+ def _save_cache(workdir: Path, home_dir: Path, profile, cache) -> str:
56
+ return store_credential(workdir, home_dir, _cache_ref(profile),
57
+ json.dumps(cache.serialize()),
58
+ service=SERVICE, auth_file=AUTH_FILE,
59
+ index_dir=INDEX_DIR)
60
+
61
+
62
+ def _app(profile, cache=None):
63
+ msal = _load_msal()
64
+ kwargs = {"token_cache": cache} if cache else {}
65
+ return msal.PublicClientApplication(
66
+ profile.client_id,
67
+ authority=f"{_get_auth_base()}/{profile.tenant_id}",
68
+ **kwargs)
69
+
70
+
71
+ def _get_auth_base() -> str:
72
+ """Authority base override for e2e (fake token endpoint)."""
73
+ return os.environ.get("SP_KNOWLEDGE_AUTH_BASE",
74
+ "https://login.microsoftonline.com")
75
+
76
+
77
+ def acquire_token(profile, workdir: Path, home_dir: Path) -> str:
78
+ """Silent first (cached refresh token); device flow only on expiry —
79
+ so normal use needs no re-sign-in after setup."""
80
+ if os.environ.get(ENV_TOKEN):
81
+ return os.environ[ENV_TOKEN]
82
+ cache = _load_cache(workdir, profile)
83
+ app = _app(profile, cache)
84
+ accounts = app.get_accounts()
85
+ if accounts:
86
+ r = app.acquire_token_silent(SCOPES, account=accounts[0])
87
+ if r and "access_token" in r:
88
+ if cache and cache.has_state_changed:
89
+ _save_cache(workdir, home_dir, profile, cache)
90
+ return r["access_token"]
91
+ raise SetupError(
92
+ "SharePoint sign-in expired — run `sharepoint-knowledge setup` to "
93
+ "re-authenticate (device code, one browser step)")
94
+
95
+
96
+ def device_sign_in(profile, workdir: Path, home_dir: Path) -> dict:
97
+ """Interactive device-code sign-in — used by `setup`. Prints the
98
+ message Microsoft returns (URL + code); blocks until approved or it
99
+ times out. Returns the stored-cache location description."""
100
+ msal = _load_msal()
101
+ cache = msal.SerializableTokenCache()
102
+ app = _app(profile, cache)
103
+ flow = app.initiate_device_flow(scopes=SCOPES)
104
+ if "user_code" not in flow:
105
+ raise SetupError(
106
+ "couldn't start device sign-in: "
107
+ f"{flow.get('error_description', flow)}")
108
+ print("\n" + flow["message"] + "\n") # "go to microsoft.com/devicelogin…"
109
+ r = app.acquire_token_by_device_flow(flow)
110
+ if "access_token" not in r:
111
+ raise SetupError(
112
+ "sign-in failed: "
113
+ f"{r.get('error_description', r.get('error', r))}")
114
+ where = _save_cache(workdir, home_dir, profile, cache)
115
+ account = r.get("id_token_claims", {})
116
+ return {"where": where,
117
+ "user": (account.get("preferred_username")
118
+ or account.get("email") or "signed in")}
@@ -0,0 +1,191 @@
1
+ """sharepoint-knowledge CLI — setup (Entra device sign-in), doctor,
2
+ prepare-models, register, update, serve, clean.
3
+
4
+ Same shape as the other knowledge CLIs. `setup` prints the exact Entra
5
+ app-registration steps for IT, collects tenant/client/site, then runs the
6
+ one-time device-code sign-in.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from confluence_knowledge.registration import register as _register
18
+ from confluence_knowledge.retrieval import prepare_models
19
+ from confluence_knowledge.settings import SetupError, ensure_gitignore
20
+
21
+ from .settings_sp import (
22
+ APP_NAME,
23
+ AUTH_ENV,
24
+ AUTH_FILE_NAME,
25
+ INDEX_DIR_NAME,
26
+ PROFILE_NAME,
27
+ SPProfile,
28
+ home,
29
+ workdir,
30
+ )
31
+
32
+ TOKEN_STEPS = """\
33
+ Entra app registration — once per tenant, done by IT (or you, if allowed):
34
+ 1. Entra portal (entra.microsoft.com) → Applications → App registrations
35
+ → New registration
36
+ 2. Name: e.g. "sharepoint-knowledge-agent" — Accounts in this org only
37
+ 3. Authentication → Enable "Allow public client flows" → YES (required
38
+ for device-code sign-in — no client secret is used or needed)
39
+ 4. API permissions → Add → Microsoft Graph → Delegated → Sites.Read.All
40
+ (+ offline_access is implied by device flow)
41
+ 5. Grant admin consent for the tenant (the delegated scope normally
42
+ requires it) — one click on the Permissions page
43
+ 6. Copy: Directory (tenant) ID and Application (client) ID — needed below
44
+
45
+ This app requests only delegated read access — it can read exactly what
46
+ the signed-in user can already read, nothing more.
47
+ """
48
+
49
+
50
+ def _setup() -> int:
51
+ print("SharePoint Knowledge — first-run setup\n")
52
+ print(TOKEN_STEPS)
53
+
54
+ tenant = input("Tenant ID (GUID): ").strip()
55
+ client = input("Client ID (application ID, GUID): ").strip()
56
+ site = input("SharePoint site URL (e.g. https://contoso.sharepoint.com"
57
+ "/sites/team): ").strip().rstrip("/")
58
+ if not (tenant and client and site):
59
+ print("tenant ID, client ID, and site URL are all required",
60
+ file=sys.stderr)
61
+ return 1
62
+
63
+ profile = SPProfile(tenant_id=tenant, client_id=client,
64
+ site_url=site, credential_ref="sp-token")
65
+ profile.save()
66
+ print(f"\nprofile saved to {home() / PROFILE_NAME}\n")
67
+
68
+ print("Sign-in (device code — one browser step). The code you get "
69
+ "never leaves the OS credential store.", flush=True)
70
+ if os.environ.get(AUTH_ENV):
71
+ print("SP_KNOWLEDGE_TOKEN is set — using it; skipping sign-in")
72
+ else:
73
+ from .auth_sp import device_sign_in
74
+ try:
75
+ r = device_sign_in(profile, workdir(), home())
76
+ print(f"signed in as {r['user']} — token cache stored at "
77
+ f"{r['where']}")
78
+ except SetupError as e:
79
+ print(f"sign-in failed: {e}", file=sys.stderr)
80
+ return 1
81
+
82
+ # make sure the workdir's gitignore hides the index + auth file
83
+ workdir().mkdir(parents=True, exist_ok=True)
84
+ ensure_gitignore(workdir(), [AUTH_FILE_NAME,
85
+ f"{INDEX_DIR_NAME}/"])
86
+
87
+ if input("\nDownload the two local models now (~175 MB, one-time)? "
88
+ "[Y/n] ").strip().lower() not in ("n", "no"):
89
+ for line in prepare_models(home()):
90
+ print(line)
91
+
92
+ print("\nsetup complete — run `sharepoint-knowledge doctor` to verify")
93
+ return 0
94
+
95
+
96
+ def _doctor() -> int:
97
+ from confluence_knowledge.update import check
98
+
99
+ from .server_sp import status
100
+ s = status()
101
+ print(json.dumps(s, indent=2))
102
+ upd = check("sharepoint-knowledge")
103
+ if upd["installed"]:
104
+ line = f"version: {upd['installed']}"
105
+ if upd["latest"]:
106
+ line += f" (latest: {upd['latest']})"
107
+ elif upd["latest"] is None:
108
+ line += " (latest: unchecked — offline or internal mirror)"
109
+ print(line)
110
+ if upd["update_available"]:
111
+ print("update available: run `sharepoint-knowledge update` "
112
+ "(or pip install -U sharepoint-knowledge[models])")
113
+ return 0
114
+
115
+
116
+ def main() -> None:
117
+ p = argparse.ArgumentParser(prog="sharepoint-knowledge",
118
+ description="Local SharePoint indexer + "
119
+ "grounded-Q&A MCP service")
120
+ sub = p.add_subparsers(dest="cmd", required=True)
121
+ sub.add_parser("setup", help="configure Entra sign-in + site")
122
+ sub.add_parser("doctor", help="report configuration + model readiness")
123
+ sub.add_parser("prepare-models", help="download the pinned local models")
124
+ sub.add_parser("update", help="upgrade this package via pip -U")
125
+ reg = sub.add_parser("register",
126
+ help="write the IDE MCP config automatically")
127
+ reg.add_argument("--ide", choices=["auto", "all", "jetbrains", "vscode"],
128
+ default="auto")
129
+ reg.add_argument("--workspace", default=None,
130
+ help="repo dir for the workspace .vscode/mcp.json")
131
+ reg.add_argument("--remove", action="store_true",
132
+ help="remove our server entry instead of adding it")
133
+ sub.add_parser("serve", help="run the stdio MCP server")
134
+ sub.add_parser("clean", help="delete all indexed data")
135
+
136
+ args = p.parse_args()
137
+ if args.cmd == "setup":
138
+ sys.exit(_setup())
139
+ if args.cmd == "doctor":
140
+ sys.exit(_doctor())
141
+ if args.cmd == "prepare-models":
142
+ for line in prepare_models(home()):
143
+ print(line)
144
+ return
145
+ if args.cmd == "update":
146
+ import subprocess
147
+ cmd = [sys.executable, "-m", "pip", "install", "-U",
148
+ "sharepoint-knowledge[models]"]
149
+ if os.name == "nt":
150
+ print("on Windows, upgrade via the venv python directly:\n "
151
+ + " ".join(f'"{c}"' if " " in c else c for c in cmd))
152
+ return
153
+ r = subprocess.run(cmd, check=False)
154
+ if r.returncode == 0:
155
+ print("\nupdated — models are pinned and unchanged; "
156
+ "run `sharepoint-knowledge doctor` to confirm")
157
+ sys.exit(r.returncode)
158
+ if args.cmd == "register":
159
+ results = _register(
160
+ ide=args.ide,
161
+ workspace=Path(args.workspace) if args.workspace else None,
162
+ remove=args.remove,
163
+ server_id=APP_NAME,
164
+ module="sharepoint_knowledge.server_sp",
165
+ env_var="SP_KNOWLEDGE_WORKDIR")
166
+ for path, action in results:
167
+ print(f"{action}: {path}")
168
+ if args.remove:
169
+ print("removed — reload the IDE to drop the server")
170
+ else:
171
+ print("registered — reload the IDE so the tools appear")
172
+ return
173
+ if args.cmd == "serve":
174
+ from .server_sp import main as serve
175
+ serve()
176
+ return
177
+ if args.cmd == "clean":
178
+ import shutil
179
+
180
+ from .settings_sp import index_root
181
+ d = index_root()
182
+ if d.exists():
183
+ shutil.rmtree(d)
184
+ print(f"deleted {d}")
185
+ else:
186
+ print("nothing to clean")
187
+ return
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()
@@ -0,0 +1,69 @@
1
+ """GET-only Microsoft Graph client for sharepoint-knowledge.
2
+
3
+ Reuses the allowlist + GET-only machinery of the Confluence client: every
4
+ request passes through the same `_validate_path` — the only Graph routes
5
+ allowed are the read ones the crawler needs. File downloads use the
6
+ pre-authenticated `@microsoft.graph.downloadUrl` fetched WITHOUT the Bearer
7
+ header — the token never leaves graph.microsoft.com.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from urllib.parse import urlparse
14
+
15
+ import httpx
16
+ from confluence_knowledge.client import (
17
+ AccessError,
18
+ ConfluenceClient,
19
+ EndpointRule,
20
+ SecurityException,
21
+ )
22
+
23
+ GRAPH_RULES = [
24
+ EndpointRule(re.compile(r"sites/[^/]+:.*"), "site by hostname:path"),
25
+ EndpointRule(re.compile(r"sites/[^/]+$"), "site info"),
26
+ EndpointRule(re.compile(r"sites/[^/]+/lists/?$"), "site lists"),
27
+ EndpointRule(re.compile(r"sites/[^/]+/lists/[^/]+/items/?$"), "list items"),
28
+ EndpointRule(re.compile(r"sites/[^/]+/lists/[^/]+/items/[^/]+/?$"), "list item"),
29
+ EndpointRule(re.compile(r"sites/[^/]+/pages/?$"), "site pages"),
30
+ EndpointRule(re.compile(r"sites/[^/]+/pages/[^/]+/?$"), "site page"),
31
+ EndpointRule(re.compile(r"sites/[^/]+/drives/?$"), "document libraries"),
32
+ EndpointRule(re.compile(r"sites/[^/]+/drive/?$"), "default drive"),
33
+ EndpointRule(re.compile(r"sites/[^/]+/drive/root:.+"), "item by path"),
34
+ EndpointRule(re.compile(r"drives/[^/]+/root/children/?$"), "library root"),
35
+ EndpointRule(re.compile(r"drives/[^/]+/items/[^/]+/children/?$"), "folder children"),
36
+ EndpointRule(re.compile(r"drives/[^/]+/items/[^/]+/?$"), "item metadata"),
37
+ EndpointRule(re.compile(r"me/?$"), "identity"),
38
+ ]
39
+
40
+
41
+ def make_client(base_url: str, token: str) -> ConfluenceClient:
42
+ """Graph client — Bearer stays on the API host only."""
43
+ return ConfluenceClient(base_url, {"Authorization": f"Bearer {token}"},
44
+ GRAPH_RULES)
45
+
46
+
47
+ def download_file(url: str, max_bytes: int = 25 * 1024 * 1024) -> bytes:
48
+ """Fetch a pre-authenticated downloadUrl — NO Authorization header.
49
+
50
+ The URL embeds a short-lived download token; sending our Bearer would
51
+ leak it to a different host (and is unnecessary — these links are
52
+ self-authenticating). https only (localhost allowed for tests).
53
+ """
54
+ u = urlparse(url)
55
+ if u.scheme != "https" and u.hostname not in ("localhost", "127.0.0.1"):
56
+ raise SecurityException(
57
+ "download URL isn't https — refusing to fetch")
58
+ with httpx.Client(timeout=60.0, follow_redirects=True) as http, \
59
+ http.stream("GET", url) as r:
60
+ # bounded stream — don't buffer an unbounded response
61
+ if r.status_code >= 400:
62
+ raise AccessError(f"download failed: HTTP {r.status_code}")
63
+ out = bytearray()
64
+ for chunk in r.iter_bytes(1 << 20):
65
+ out += chunk
66
+ if len(out) > max_bytes:
67
+ raise AccessError(
68
+ f"file exceeds {max_bytes} bytes — refusing to buffer")
69
+ return bytes(out)